From 6e8bd1a274a51afc027a236b749db7d597a68901 Mon Sep 17 00:00:00 2001 From: ibnbd <2509519+ibnbd@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:41:58 +0000 Subject: [PATCH 1/8] Improve scan performance, accuracy, recipes, pressure helper, antivirus, and Slint UI (v0.3.6) Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- Cargo.lock | 22 +- Cargo.toml | 2 +- .../cleanton-antivirus/src/engines/hashdb.rs | 90 ++- .../src/engines/persistence.rs | 114 +++- .../cleanton-antivirus/src/engines/rules.rs | 303 ++++++--- .../src/engines/script_behavior.rs | 227 ++++++- crates/cleanton-antivirus/src/extract.rs | 47 +- crates/cleanton-antivirus/src/scanner.rs | 100 ++- crates/cleanton-app/src/main.rs | 355 +++++++++- crates/cleanton-app/ui/app.slint | 385 +++++++++-- crates/cleanton-app/ui/widgets.slint | 358 +++++----- crates/cleanton-cli/src/main.rs | 146 ++++- crates/cleanton-common/src/config.rs | 37 +- crates/cleanton-common/src/lib.rs | 5 +- crates/cleanton-common/src/types.rs | 9 + crates/cleanton-engine/src/analyzer.rs | 163 +++-- crates/cleanton-engine/src/background.rs | 332 ++++++++-- crates/cleanton-engine/src/lib.rs | 5 +- crates/cleanton-engine/src/ml.rs | 50 +- crates/cleanton-engine/src/orchestrator.rs | 25 +- crates/cleanton-engine/src/security.rs | 103 ++- crates/cleanton-engine/src/suggestions.rs | 36 +- crates/cleanton-recipes/src/builtin.rs | 72 ++ crates/cleanton-recipes/src/lib.rs | 2 +- crates/cleanton-recipes/src/matcher.rs | 619 +++++++++++++++++- crates/cleanton-recipes/src/validator.rs | 2 +- crates/cleanton-scanner/src/capacity_ffi.rs | 285 +++++++- crates/cleanton-scanner/src/lib.rs | 3 +- crates/cleanton-scanner/src/project.rs | 152 ++++- crates/cleanton-scanner/src/walker.rs | 336 +++++++--- crates/cleanton-services/src/launchd.rs | 90 ++- crates/cleanton-services/src/lib.rs | 1 + crates/cleanton-services/src/manager.rs | 63 +- crates/cleanton-services/src/safety.rs | 20 + crates/cleanton-services/src/systemd.rs | 285 ++++++++ crates/cleanton-services/src/types.rs | 1 + crates/cleanton-ui/src/app.rs | 20 +- crates/cleanton-ui/src/lib.rs | 6 + crates/cleanton-ui/src/screens/dashboard.rs | 64 +- crates/cleanton-ui/src/screens/services.rs | 1 + doc/RECIPES.md | 28 + recipes/ai-cursor-cache.toml | 18 +- recipes/ai-windsurf-cache.toml | 15 +- recipes/android-build-caches.toml | 33 + recipes/android-sdk-system-images.toml | 10 +- recipes/apps-electron-cache-data.toml | 14 +- recipes/apps-steam-shader-cache.toml | 15 +- recipes/browser-chrome-http-cache.toml | 21 +- recipes/c-cpp-build-variants.toml | 58 ++ recipes/c-cpp-ccache.toml | 6 +- recipes/c-cpp-vcpkg-cache.toml | 27 + recipes/clojure-build-caches.toml | 26 + recipes/dart-pub-cache.toml | 7 +- recipes/dart-tool-build-dirs.toml | 21 + recipes/dotnet-http-cache.toml | 32 + recipes/dotnet-nuget-cache.toml | 1 - recipes/dotnet-obj-dirs.toml | 24 + recipes/godot-import-cache.toml | 21 + recipes/haskell-cabal-build-dirs.toml | 28 + recipes/ide-editor-caches.toml | 69 ++ recipes/ide-vscode-caches.toml | 33 +- recipes/ide-vscode-extension-vsixs.toml | 56 ++ recipes/jvm-gradle-caches.toml | 2 +- recipes/jvm-maven-target-dirs.toml | 21 + recipes/jvm-project-gradle-dirs.toml | 21 + recipes/jvm-scala-build-dirs.toml | 35 + recipes/node-bundler-caches.toml | 27 +- recipes/node-modern-framework-dirs.toml | 65 ++ recipes/node-nx-cache-dirs.toml | 30 + recipes/node-pnpm-store.toml | 12 +- recipes/ocaml-dune-build-dirs.toml | 21 + recipes/php-tool-caches.toml | 33 + recipes/python-pixi-cache.toml | 8 +- recipes/python-poetry-cache.toml | 4 +- recipes/python-tool-caches.toml | 35 +- recipes/python-uv-cache.toml | 8 +- recipes/rust-cargo-git.toml | 2 +- recipes/rust-cargo-registry-cache.toml | 2 +- recipes/rust-cargo-registry-src.toml | 2 +- recipes/swift-derived-data.toml | 2 +- 80 files changed, 4949 insertions(+), 880 deletions(-) create mode 100644 crates/cleanton-services/src/systemd.rs create mode 100644 recipes/android-build-caches.toml create mode 100644 recipes/c-cpp-build-variants.toml create mode 100644 recipes/c-cpp-vcpkg-cache.toml create mode 100644 recipes/clojure-build-caches.toml create mode 100644 recipes/dart-tool-build-dirs.toml create mode 100644 recipes/dotnet-http-cache.toml create mode 100644 recipes/dotnet-obj-dirs.toml create mode 100644 recipes/godot-import-cache.toml create mode 100644 recipes/haskell-cabal-build-dirs.toml create mode 100644 recipes/ide-editor-caches.toml create mode 100644 recipes/ide-vscode-extension-vsixs.toml create mode 100644 recipes/jvm-maven-target-dirs.toml create mode 100644 recipes/jvm-project-gradle-dirs.toml create mode 100644 recipes/jvm-scala-build-dirs.toml create mode 100644 recipes/node-modern-framework-dirs.toml create mode 100644 recipes/node-nx-cache-dirs.toml create mode 100644 recipes/ocaml-dune-build-dirs.toml create mode 100644 recipes/php-tool-caches.toml diff --git a/Cargo.lock b/Cargo.lock index 104be29..f4592da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1148,7 +1148,7 @@ checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" [[package]] name = "cleanton-antivirus" -version = "0.3.5" +version = "0.3.6" dependencies = [ "base64 0.22.1", "flate2", @@ -1170,7 +1170,7 @@ dependencies = [ [[package]] name = "cleanton-app" -version = "0.3.5" +version = "0.3.6" dependencies = [ "cleanton-common", "cleanton-engine", @@ -1188,7 +1188,7 @@ dependencies = [ [[package]] name = "cleanton-cli" -version = "0.3.5" +version = "0.3.6" dependencies = [ "clap", "cleanton-common", @@ -1206,7 +1206,7 @@ dependencies = [ [[package]] name = "cleanton-common" -version = "0.3.5" +version = "0.3.6" dependencies = [ "chrono", "humansize", @@ -1221,7 +1221,7 @@ dependencies = [ [[package]] name = "cleanton-engine" -version = "0.3.5" +version = "0.3.6" dependencies = [ "bollard", "chrono", @@ -1244,7 +1244,7 @@ dependencies = [ [[package]] name = "cleanton-ml" -version = "0.3.5" +version = "0.3.6" dependencies = [ "cleanton-common", "serde", @@ -1256,7 +1256,7 @@ dependencies = [ [[package]] name = "cleanton-packages" -version = "0.3.5" +version = "0.3.6" dependencies = [ "cleanton-common", "serde", @@ -1268,7 +1268,7 @@ dependencies = [ [[package]] name = "cleanton-recipes" -version = "0.3.5" +version = "0.3.6" dependencies = [ "cleanton-common", "glob", @@ -1280,7 +1280,7 @@ dependencies = [ [[package]] name = "cleanton-scanner" -version = "0.3.5" +version = "0.3.6" dependencies = [ "cleanton-common", "glob", @@ -1294,7 +1294,7 @@ dependencies = [ [[package]] name = "cleanton-services" -version = "0.3.5" +version = "0.3.6" dependencies = [ "cleanton-common", "plist", @@ -1307,7 +1307,7 @@ dependencies = [ [[package]] name = "cleanton-ui" -version = "0.3.5" +version = "0.3.6" dependencies = [ "cleanton-common", "cleanton-engine", diff --git a/Cargo.toml b/Cargo.toml index f5bbe82..be99254 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ members = [ ] [workspace.package] -version = "0.3.5" +version = "0.3.6" edition = "2024" rust-version = "1.99.1" license = "MIT OR Apache-2.0" diff --git a/crates/cleanton-antivirus/src/engines/hashdb.rs b/crates/cleanton-antivirus/src/engines/hashdb.rs index 7383e62..eb293b7 100644 --- a/crates/cleanton-antivirus/src/engines/hashdb.rs +++ b/crates/cleanton-antivirus/src/engines/hashdb.rs @@ -499,6 +499,10 @@ pub struct HashSignatureEngine { /// Additional entries loaded from `ScanConfig::signature_db_paths`. /// Stored as owned strings since the paths are runtime data. extra: HashMap, + /// True when `extra` contains at least one 40-hex SHA-1 signature. + has_extra_sha1: bool, + /// True when `extra` contains at least one 32-hex MD5 signature. + has_extra_md5: bool, } impl HashSignatureEngine { @@ -526,7 +530,13 @@ impl HashSignatureEngine { } } } - Self { extra } + let has_extra_sha1 = extra.keys().any(|k| k.len() == 40); + let has_extra_md5 = extra.keys().any(|k| k.len() == 32); + Self { + extra, + has_extra_sha1, + has_extra_md5, + } } /// Look up a single hex hash in both the bundled and extra databases. @@ -631,11 +641,23 @@ fn parse_db_contents( /// Returns true if the file content contains the EICAR test string as a /// substring (handles CRLF variants and minor padding). fn contains_eicar_string(data: &[u8]) -> bool { - // The EICAR string is pure ASCII; a substring-of-bytes search is sufficient. let eicar = EICAR_STRING.as_bytes(); - // Use a simple sliding-window search — safe for adversarial input because - // len is bounded by ScanConfig::max_file_size. - data.windows(eicar.len()).any(|w| w == eicar) + if data.len() < eicar.len() { + return false; + } + let last_start = data.len() - eicar.len(); + let mut pos = 0usize; + while pos <= last_start { + let Some(rel) = data.get(pos..=last_start).and_then(|s| s.iter().position(|&b| b == b'X')) else { + return false; + }; + let idx = pos + rel; + if data.get(idx..idx + eicar.len()) == Some(eicar) { + return true; + } + pos = idx + 1; + } + false } // ─── DetectionEngine impl ──────────────────────────────────────────────────── @@ -666,39 +688,43 @@ impl DetectionEngine for HashSignatureEngine { emit_if_new(f, &label, &mut findings); } - // 2. SHA-1 - let sha1 = Self::sha1_hex(&file.data); - if let Some((label, sev)) = self.lookup(&sha1) { - let f = self.finding_for_hash(file, "sha1", &sha1, &label, sev); - emit_if_new(f, &label, &mut findings); + // 2. SHA-1 — only the 68-byte EICAR entry in `BUNDLED_HASHES` uses + // SHA-1 unless an operator-supplied DB added 40-char digests. + if self.has_extra_sha1 || file.data.len() == EICAR_STRING.len() { + let sha1 = Self::sha1_hex(&file.data); + if let Some((label, sev)) = self.lookup(&sha1) { + let f = self.finding_for_hash(file, "sha1", &sha1, &label, sev); + emit_if_new(f, &label, &mut findings); + } } - // 3. MD5 - let md5 = Self::md5_hex(&file.data); - if let Some((label, sev)) = self.lookup(&md5) { - let f = self.finding_for_hash(file, "md5", &md5, &label, sev); - emit_if_new(f, &label, &mut findings); + // 3. MD5 — only the 68-byte EICAR entry in `BUNDLED_HASHES` uses + // MD5 unless an operator-supplied DB added 32-char digests. + if self.has_extra_md5 || file.data.len() == EICAR_STRING.len() { + let md5 = Self::md5_hex(&file.data); + if let Some((label, sev)) = self.lookup(&md5) { + let f = self.finding_for_hash(file, "md5", &md5, &label, sev); + emit_if_new(f, &label, &mut findings); + } } // 4. EICAR string scan — catches the test file even if its hash // differs (e.g. wrapped in a ZIP that wasn't extracted). - if contains_eicar_string(&file.data) { - let label = "EICAR_TEST_FILE"; - if !emitted_labels.iter().any(|l| l == label) { - let f = Finding::new( - "hashdb", - ThreatCategory::KnownMalware, - Severity::Critical, - Confidence::High, - "EICAR Anti-Virus Test File (string match)", - "File contains the canonical EICAR test string. \ - This is the industry-standard test for antivirus detection.", - ) - .with_rule_id(label) - .with_location(FileLocation::file(&file.rel_path)) - .with_evidence("EICAR_STRING_MATCH:X5O!P%@AP[4\\PZX54(P^)7CC)7}$...", 160); - findings.push(f); - } + let label = "EICAR_TEST_FILE"; + if !emitted_labels.iter().any(|l| l == label) && contains_eicar_string(&file.data) { + let f = Finding::new( + "hashdb", + ThreatCategory::KnownMalware, + Severity::Critical, + Confidence::High, + "EICAR Anti-Virus Test File (string match)", + "File contains the canonical EICAR test string. \ + This is the industry-standard test for antivirus detection.", + ) + .with_rule_id(label) + .with_location(FileLocation::file(&file.rel_path)) + .with_evidence("EICAR_STRING_MATCH:X5O!P%@AP[4\\PZX54(P^)7CC)7}$...", 160); + findings.push(f); } } diff --git a/crates/cleanton-antivirus/src/engines/persistence.rs b/crates/cleanton-antivirus/src/engines/persistence.rs index 86cb9ec..a66ddbf 100644 --- a/crates/cleanton-antivirus/src/engines/persistence.rs +++ b/crates/cleanton-antivirus/src/engines/persistence.rs @@ -117,6 +117,8 @@ struct Patterns { cron_schedule: Regex, /// `ExecStart` / `ExecStartPre` / `ExecStartPost` in a systemd unit. systemd_exec: Regex, + /// `Exec=` in an XDG `~/.config/autostart/*.desktop` entry. + desktop_exec: Regex, /// A whitespace/comment-only line, or a leading `#` comment. skippable_line: Regex, } @@ -173,6 +175,9 @@ impl Patterns { systemd_exec: Regex::new(r"(?im)^\s*ExecStart(?:Pre|Post)?\s*=\s*(.+)$") .expect("invariant: systemd exec regex"), + desktop_exec: Regex::new(r"(?im)^\s*Exec\s*=\s*(.+)$") + .expect("invariant: desktop autostart exec regex"), + skippable_line: Regex::new(r"^\s*(?:#.*)?$").expect("invariant: blank/comment regex"), } } @@ -198,12 +203,14 @@ fn patterns() -> &'static Patterns { enum TargetKind { /// A launchd job definition (`~/Library/Launch{Agents,Daemons}/*.plist`). LaunchAgent, - /// A shell startup file (`.zshrc`, `.bashrc`, `.profile`, …). + /// A shell startup file (`.zshrc`, `.bashrc`, `.profile`, `config.fish`, …). ShellStartup, /// A crontab or a `cron.d` style drop-in. Crontab, /// A systemd user unit (`~/.config/systemd/user/*.service`). SystemdUnit, + /// An XDG desktop autostart entry (`~/.config/autostart/*.desktop`). + DesktopAutostart, } /// Shell startup files whose basename marks them as per-login/per-shell hooks. @@ -244,7 +251,10 @@ fn classify(path: &str) -> Option { // NOTE: the containment checks deliberately omit a leading `/`. `rel_path` // is relative to whatever root the loader scanned (a home directory, a // package), so it may or may not start with one. - if SHELL_STARTUP_NAMES.contains(&basename) || lower.contains("etc/profile.d/") { + if SHELL_STARTUP_NAMES.contains(&basename) + || lower.contains("etc/profile.d/") + || lower.ends_with("fish/config.fish") + { return Some(TargetKind::ShellStartup); } @@ -261,6 +271,10 @@ fn classify(path: &str) -> Option { return Some(TargetKind::SystemdUnit); } + if lower.contains("autostart/") && basename.ends_with(".desktop") { + return Some(TargetKind::DesktopAutostart); + } + None } @@ -414,6 +428,9 @@ impl DetectionEngine for PersistenceEngine { TargetKind::SystemdUnit => { scan_systemd_unit(file, &text, patterns, &mut budget, &mut findings) } + TargetKind::DesktopAutostart => { + scan_desktop_autostart(file, &text, patterns, &mut budget, &mut findings) + } } } @@ -813,6 +830,20 @@ fn scan_systemd_unit( Some(line_number), cleaned, )); + } else if patterns.decode_exec.is_match(cleaned) + && budget.allow("PERSIST_SYSTEMD_DECODE_EXEC") + { + findings.push(finding( + "PERSIST_SYSTEMD_DECODE_EXEC", + ThreatCategory::Obfuscation, + Severity::Critical, + Confidence::High, + "systemd user unit decodes and executes an embedded payload", + "This unit's ExecStart decodes an encoded blob and executes it via a shell.", + file, + Some(line_number), + cleaned, + )); } else if first_path_token(cleaned).is_some_and(is_suspicious_exec_path) && budget.allow("PERSIST_SYSTEMD_HIDDEN_EXEC") { @@ -831,6 +862,77 @@ fn scan_systemd_unit( } } +// --------------------------------------------------------------------------- +// XDG desktop autostart entries (`~/.config/autostart/*.desktop`) +// --------------------------------------------------------------------------- + +fn scan_desktop_autostart( + file: &FileUnit, + text: &str, + patterns: &Patterns, + budget: &mut RuleBudget, + findings: &mut Vec, +) { + for (index, line) in text.lines().enumerate() { + let line_number = index + 1; + if patterns.skippable_line.is_match(line) { + continue; + } + let Some(command) = patterns + .desktop_exec + .captures(line) + .and_then(|caps| caps.get(1)) + .map(|m| m.as_str().trim()) + else { + continue; + }; + + if patterns.download_exec.is_match(command) + && budget.allow("PERSIST_AUTOSTART_DOWNLOAD_EXEC") + { + findings.push(finding( + "PERSIST_AUTOSTART_DOWNLOAD_EXEC", + ThreatCategory::CommandExecution, + Severity::Critical, + Confidence::High, + "Desktop autostart entry downloads and executes remote code", + "This XDG autostart `.desktop` file pipes a remote download into a shell at every desktop login.", + file, + Some(line_number), + command, + )); + } else if patterns.decode_exec.is_match(command) + && budget.allow("PERSIST_AUTOSTART_DECODE_EXEC") + { + findings.push(finding( + "PERSIST_AUTOSTART_DECODE_EXEC", + ThreatCategory::Obfuscation, + Severity::Critical, + Confidence::High, + "Desktop autostart entry decodes and executes an embedded payload", + "This XDG autostart `.desktop` file decodes an encoded blob and executes it at login.", + file, + Some(line_number), + command, + )); + } else if first_path_token(command).is_some_and(is_suspicious_exec_path) + && budget.allow("PERSIST_AUTOSTART_HIDDEN_EXEC") + { + findings.push(finding( + "PERSIST_AUTOSTART_HIDDEN_EXEC", + ThreatCategory::FilesystemAbuse, + Severity::High, + Confidence::Medium, + "Desktop autostart entry runs a payload from a hidden or temporary directory", + "This XDG autostart `.desktop` file executes a binary parked in `/tmp` or a hidden dot-directory on login.", + file, + Some(line_number), + command, + )); + } + } +} + // --------------------------------------------------------------------------- // Finding construction // --------------------------------------------------------------------------- @@ -1142,6 +1244,14 @@ mod tests { classify(".config/systemd/user/a.service"), Some(TargetKind::SystemdUnit) ); + assert_eq!( + classify(".config/fish/config.fish"), + Some(TargetKind::ShellStartup) + ); + assert_eq!( + classify(".config/autostart/updater.desktop"), + Some(TargetKind::DesktopAutostart) + ); assert_eq!(classify("src/main.rs"), None); } diff --git a/crates/cleanton-antivirus/src/engines/rules.rs b/crates/cleanton-antivirus/src/engines/rules.rs index 351746d..e8ec1c4 100644 --- a/crates/cleanton-antivirus/src/engines/rules.rs +++ b/crates/cleanton-antivirus/src/engines/rules.rs @@ -10,7 +10,7 @@ use std::sync::OnceLock; -use regex::Regex; +use regex::{Regex, RegexSet, RegexSetBuilder}; use tracing::warn; use crate::{ @@ -53,53 +53,6 @@ struct Rule { condition: Condition, } -// ── Compiled regex cache ───────────────────────────────────────────────────── -// -// Each regex-pattern string is used as the key into ONE global per-pattern -// OnceLock. Because rules are 'static and pattern strings are 'static literals -// we can keep this tidy with a macro that builds a function returning the -// compiled regex or None if compilation fails (it never will for our literals, -// but we don't panic on that path). - -/// Compile (once) and return a reference to the regex for `src`. -/// Returns `None` only if the regex is invalid — which cannot happen for our -/// bundled literals; the None path prevents a panic should the regex crate -/// change its syntax semantics. -fn compiled(src: &'static str) -> Option<&'static Regex> { - use std::collections::HashMap; - use std::sync::Mutex; - - // Key: the &'static str itself (which is Send + Sync). - // Value: a `&'static Regex` obtained by leaking a Box on first compile. - // Both &'static str and &'static Regex are Send + Sync, so this static is - // safe in a multi-threaded context. - static CACHE: OnceLock>> = OnceLock::new(); - let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); - - // Fast path: already compiled. - { - let guard = cache.lock().unwrap_or_else(|p| p.into_inner()); - if let Some(&r) = guard.get(src) { - return Some(r); - } - } - - // Slow path: compile, leak, insert. - match Regex::new(src) { - Ok(r) => { - let leaked: &'static Regex = Box::leak(Box::new(r)); - let mut guard = cache.lock().unwrap_or_else(|p| p.into_inner()); - // Use entry to avoid a double-insert if two threads race here. - let stored = guard.entry(src).or_insert(leaked); - Some(*stored) - } - Err(e) => { - warn!(pattern = src, error = %e, "rules engine: failed to compile regex"); - None - } - } -} - // ── Bundled rule patterns (static data) ────────────────────────────────────── // // All pattern literals live here so they are compiled exactly once. Using @@ -729,6 +682,70 @@ static RULE_CRED_RUBYGEMS: Rule = Rule { condition: Condition::AnyOf, }; +// Rule 33 – macOS Keychain secret dump (`security dump-keychain` / `-w`). +// +// Atomic macOS Stealer (AMOS), Poseidon, and Cthulhu harvest macOS Keychain +// items via `security dump-keychain` or `security find-generic-password -w`. +static RULE_MACOS_KEYCHAIN_DUMP: Rule = Rule { + id: "CRED_MACOS_KEYCHAIN_DUMP", + category: ThreatCategory::CredentialAccess, + severity: Severity::Critical, + confidence: Confidence::High, + title: "macOS Keychain credential extraction command", + desc: "Invokes the macOS `security` CLI to dump a keychain or print stored \ + plaintext passwords (`dump-keychain`, `find-generic-password -w`, or \ + `find-internet-password -w`), a signature technique of AMOS/Poseidon stealers.", + patterns: &[ + Pattern::Regex(r"\bsecurity\s+dump-keychain\b"), + Pattern::Regex( + r"\bsecurity\s+find-(?:generic|internet)-password\b[^\n]{0,120}\s-(?:[a-zA-Z]*w|g)\b", + ), + ], + condition: Condition::AnyOf, +}; + +// Rule 34 – macOS AppleScript credential phishing dialog (`osascript` + hidden answer). +// +// Commodity macOS stealers pop a fake system password dialog via +// `osascript -e 'display dialog ... with hidden answer'` to capture the user's +// login password in cleartext. +static RULE_MACOS_OSASCRIPT_CRED_PHISH: Rule = Rule { + id: "CRED_OSASCRIPT_PASSWORD_PROMPT", + category: ThreatCategory::CredentialAccess, + severity: Severity::High, + confidence: Confidence::High, + title: "AppleScript hidden-answer password prompt", + desc: "Combines `osascript`, `display dialog`, and `hidden answer` to prompt \ + the user for a password in a spoofed macOS modal dialog.", + patterns: &[ + Pattern::Regex(r"\bosascript\b"), + Pattern::Regex(r"(?i)\bdisplay\s+dialog\b"), + Pattern::Regex(r"(?i)\bwith\s+hidden\s+answer\b"), + ], + condition: Condition::AllOf, +}; + +// Rule 35 – Linux Secret Service / GNOME Keyring / KWallet credential access. +// +// Linux supply-chain payloads and cross-platform stealers read GNOME Keyring +// (`~/.local/share/keyrings/login.keyring`) or dump secrets via `secret-tool` / +// `kwallet-query`. +static RULE_LINUX_SECRET_SERVICE: Rule = Rule { + id: "CRED_LINUX_SECRET_SERVICE", + category: ThreatCategory::CredentialAccess, + severity: Severity::High, + confidence: Confidence::High, + title: "Linux keyring / Secret Service credential access", + desc: "References GNOME Keyring files (`~/.local/share/keyrings/*.keyring`) \ + or invokes `secret-tool` / `kwallet-query` to extract stored credentials.", + patterns: &[ + Pattern::Regex(r"\.local/share/keyrings/[A-Za-z0-9_.-]+\.keyring"), + Pattern::Regex(r"\bsecret-tool\s+search\b[^\n]{0,80}--(?:all|unlock)\b"), + Pattern::Regex(r"\bkwallet-query\b[^\n]{0,80}\s-r\b"), + ], + condition: Condition::AnyOf, +}; + /// All bundled rules, in the order they are checked. static ALL_RULES: &[&Rule] = &[ &RULE_EXEC_CURL_BASH, @@ -763,22 +780,88 @@ static ALL_RULES: &[&Rule] = &[ &RULE_EVAL_DYNAMIC, &RULE_CRYPTO_WALLET_FILES, &RULE_CRED_RUBYGEMS, + &RULE_MACOS_KEYCHAIN_DUMP, + &RULE_MACOS_OSASCRIPT_CRED_PHISH, + &RULE_LINUX_SECRET_SERVICE, ]; -// ── Pattern evaluation helpers ─────────────────────────────────────────────── +// ── Lock-free precompiled ruleset + RegexSet prefilter ─────────────────────── + +enum CompiledPat { + Literal(&'static str), + Regex(Option), +} -/// Returns `true` if `text` contains the pattern's literal/regex match. -/// Also returns the byte-offset of the first match (for line resolution). -fn pattern_matches(pat: &Pattern, text: &str) -> Option { - match pat { - Pattern::Literal(lit) => text.find(lit), - Pattern::Regex(src) => { - let re = compiled(src)?; - re.find(text).map(|m| m.start()) +impl CompiledPat { + fn find_offset(&self, text: &str) -> Option { + match self { + Self::Literal(lit) => text.find(lit), + Self::Regex(Some(re)) => re.find(text).map(|m| m.start()), + Self::Regex(None) => None, } } } +struct CompiledRuleEntry { + rule: &'static Rule, + set_start: usize, + patterns: Vec, +} + +struct CompiledRuleset { + regex_set: Option, + entries: Vec, +} + +fn compiled_ruleset() -> &'static CompiledRuleset { + static RULESET: OnceLock = OnceLock::new(); + RULESET.get_or_init(|| { + let mut set_exprs: Vec = Vec::new(); + let mut entries: Vec = Vec::with_capacity(ALL_RULES.len()); + + for &rule in ALL_RULES { + let set_start = set_exprs.len(); + let mut patterns = Vec::with_capacity(rule.patterns.len()); + for pat in rule.patterns { + match pat { + Pattern::Literal(lit) => { + set_exprs.push(regex::escape(lit)); + patterns.push(CompiledPat::Literal(lit)); + } + Pattern::Regex(src) => match Regex::new(src) { + Ok(re) => { + set_exprs.push((*src).to_owned()); + patterns.push(CompiledPat::Regex(Some(re))); + } + Err(e) => { + warn!(pattern = src, error = %e, "rules engine: failed to compile regex"); + set_exprs.push(r"[^\s\S]".to_owned()); + patterns.push(CompiledPat::Regex(None)); + } + }, + } + } + entries.push(CompiledRuleEntry { + rule, + set_start, + patterns, + }); + } + + let regex_set = RegexSetBuilder::new(&set_exprs) + .size_limit(64 * 1024 * 1024) + .dfa_size_limit(64 * 1024 * 1024) + .build() + .map_err(|e| { + warn!(error = %e, "rules engine: RegexSet prefilter fallback to per-rule scan"); + e + }) + .ok(); + + CompiledRuleset { regex_set, entries } + }) +} + /// For a given byte offset in `text`, return the 1-based line number. fn byte_offset_to_line(text: &str, offset: usize) -> usize { let safe_offset = offset.min(text.len()); @@ -801,47 +884,51 @@ fn evidence_snippet(text: &str, offset: usize) -> String { // ── Rule evaluation ─────────────────────────────────────────────────────────── -/// Evaluate one rule against `text`. Returns `Some((first_match_offset, -/// matched_pattern_index))` if the condition is satisfied. -fn evaluate_rule(rule: &Rule, text: &str) -> Option { - // Pre-filter: for AnyOf/AllOf/NOfThem we do a quick cheap literal check - // first — if the rule has no literals this is a no-op. This avoids running - // the heavier regex machinery on obviously-irrelevant files. - let has_literal_pre_filter = rule - .patterns - .iter() - .any(|p| matches!(p, Pattern::Literal(_))); - if has_literal_pre_filter { - let any_literal_hit = rule.patterns.iter().any(|p| { - if let Pattern::Literal(lit) = p { - text.contains(lit) - } else { - false +/// Evaluate one compiled rule against `text`, optionally guided by a `RegexSet` +/// match bitmap so non-matching rules exit in O(1) without scanning `text`. +fn evaluate_compiled_rule( + entry: &CompiledRuleEntry, + set_matches: Option<®ex::SetMatches>, + text: &str, +) -> Option { + if let Some(matches) = set_matches { + let hit_count = (0..entry.patterns.len()) + .filter(|&i| matches.matched(entry.set_start + i)) + .count(); + let satisfied = match &entry.rule.condition { + Condition::AnyOf => hit_count >= 1, + Condition::AllOf => hit_count == entry.patterns.len(), + Condition::NOfThem(n) => hit_count >= *n, + }; + if !satisfied { + return None; + } + let mut first_offset: Option = None; + for (i, pat) in entry.patterns.iter().enumerate() { + if matches.matched(entry.set_start + i) + && let Some(off) = pat.find_offset(text) + && first_offset.is_none_or(|cur| off < cur) + { + first_offset = Some(off); } - }); - // For AnyOf/NOfThem we skip if no literal matched AND there are only - // literals in the pattern list — handled below naturally. No early-exit - // here; we still let the regex patterns run. - let _ = any_literal_hit; // used as optimisation hint below + } + return first_offset; } let mut first_offset: Option = None; let mut match_count: usize = 0; - let mut matched_indices: Vec = Vec::with_capacity(rule.patterns.len()); - - for (i, pat) in rule.patterns.iter().enumerate() { - if let Some(off) = pattern_matches(pat, text) { + for pat in &entry.patterns { + if let Some(off) = pat.find_offset(text) { match_count += 1; - matched_indices.push(i); - if first_offset.is_none() || off < first_offset.unwrap_or(usize::MAX) { + if first_offset.is_none_or(|cur| off < cur) { first_offset = Some(off); } } } - let fires = match &rule.condition { + let fires = match &entry.rule.condition { Condition::AnyOf => match_count >= 1, - Condition::AllOf => match_count == rule.patterns.len(), + Condition::AllOf => match_count == entry.patterns.len(), Condition::NOfThem(n) => match_count >= *n, }; @@ -882,6 +969,7 @@ impl DetectionEngine for RuleEngine { fn scan(&self, ctx: &ScanContext<'_>) -> Vec { let mut findings: Vec = Vec::new(); + let ruleset = compiled_ruleset(); // Cap text fed to regex evaluation to guard against adversarially huge // files causing slow NFA traversal. Mirrors ioc.rs's MAX_SCAN_BYTES. @@ -893,13 +981,21 @@ impl DetectionEngine for RuleEngine { let window = crate::engine::scan_window(file.text_lossy(), MAX_SCAN_BYTES); let text: &str = &window; - for rule in ALL_RULES.iter() { + let set_matches = ruleset.regex_set.as_ref().map(|set| set.matches(text)); + if let Some(ref matches) = set_matches + && !matches.matched_any() + { + continue; + } + + for entry in &ruleset.entries { + let rule = entry.rule; // Check operator allowlist. if ctx.config.is_allowlisted(rule.id) { continue; } - if let Some(offset) = evaluate_rule(rule, text) { + if let Some(offset) = evaluate_compiled_rule(entry, set_matches.as_ref(), text) { let line = byte_offset_to_line(text, offset); let snippet = evidence_snippet(text, offset); @@ -1853,4 +1949,35 @@ mod tests { "benign gem manifest must not fire; findings: {findings:?}" ); } + + // ── CRED_MACOS_KEYCHAIN_DUMP / OSASCRIPT / LINUX_SECRET_SERVICE ────────── + + #[test] + fn test_macos_keychain_dump_positive_and_negative() { + let pos = scan_text("execSync('security find-generic-password -s github.com -w')"); + assert!(has_rule(&pos, "CRED_MACOS_KEYCHAIN_DUMP")); + + let neg = scan_text("execSync('security verify-cert -c cert.pem')"); + assert!(!has_rule(&neg, "CRED_MACOS_KEYCHAIN_DUMP")); + } + + #[test] + fn test_macos_osascript_phish_positive_and_negative() { + let pos = scan_text( + r#"osascript -e 'display dialog "System Preferences requires your password" default answer "" with hidden answer'"#, + ); + assert!(has_rule(&pos, "CRED_OSASCRIPT_PASSWORD_PROMPT")); + + let neg = scan_text(r#"osascript -e 'display notification "Build complete"'"#); + assert!(!has_rule(&neg, "CRED_OSASCRIPT_PASSWORD_PROMPT")); + } + + #[test] + fn test_linux_secret_service_positive_and_negative() { + let pos = scan_text("fs.readFileSync(home + '/.local/share/keyrings/login.keyring')"); + assert!(has_rule(&pos, "CRED_LINUX_SECRET_SERVICE")); + + let neg = scan_text("fs.readFileSync(home + '/.local/share/applications/app.desktop')"); + assert!(!has_rule(&neg, "CRED_LINUX_SECRET_SERVICE")); + } } diff --git a/crates/cleanton-antivirus/src/engines/script_behavior.rs b/crates/cleanton-antivirus/src/engines/script_behavior.rs index 92cc1e4..8c9c050 100644 --- a/crates/cleanton-antivirus/src/engines/script_behavior.rs +++ b/crates/cleanton-antivirus/src/engines/script_behavior.rs @@ -8,9 +8,10 @@ //! No regex is compiled more than once — all patterns are built lazily via //! [`std::sync::OnceLock`]. +use std::cell::Cell; use std::sync::OnceLock; -use regex::Regex; +use regex::{Regex, RegexSet, RegexSetBuilder}; use crate::engine::{DetectionEngine, FileKind, FileUnit, ScanContext}; use crate::types::{Confidence, FileLocation, Finding, Severity, ThreatCategory}; @@ -148,16 +149,45 @@ lazy_re!(RE_PTH_OS_BRANCH_EXEC, ""); // call. This separate OnceLock holds the complementary pattern that catches it. lazy_re!(RE_EVAL_BUF_B64_OUTER, ""); +thread_local! { + /// Per-thread active-pattern bitmap set during `analyze_file`. Defaults to + /// `u128::MAX` so standalone `get_re` callers (e.g. unit tests) run unfiltered. + static ACTIVE_PATTERN_MASK: Cell = const { Cell::new(u128::MAX) }; +} + +struct MaskGuard(u128); + +impl MaskGuard { + fn set(mask: u128) -> Self { + let prev = ACTIVE_PATTERN_MASK.with(|cell| { + let old = cell.get(); + cell.set(mask); + old + }); + Self(prev) + } +} + +impl Drop for MaskGuard { + fn drop(&mut self) { + let prev = self.0; + ACTIVE_PATTERN_MASK.with(|cell| cell.set(prev)); + } +} + // Helper: initialise a OnceLock from a pattern string, returning None -// on a regex compile error so the engine degrades gracefully. +// when the single-pass `RegexSet` prefilter already proved `pattern` cannot +// match the current file. fn get_re(lock: &'static OnceLock, pattern: &'static str) -> Option<&'static Regex> { + let mask = ACTIVE_PATTERN_MASK.with(Cell::get); + if mask != u128::MAX + && let Some(bit) = script_prefilter().bit_for(lock) + && (mask & (1_u128 << bit)) == 0 + { + return None; + } Some(lock.get_or_init(|| { - // Patterns are all compile-time constants; this should never fail. - // We assert at test time that every pattern compiles. Regex::new(pattern).unwrap_or_else(|_| { - // Fallback: a regex that matches nothing, so the engine - // continues without this rule rather than panicking. - // Fallback matches nothing: a character class that can never match. Regex::new(r"[^\s\S]").expect("invariant: never-match fallback regex must compile") }) })) @@ -501,6 +531,159 @@ fn bracket_delta(s: &str) -> i32 { delta } +/// Fast single-pass `RegexSet` prefilter over all 74 script behavior patterns. +struct ScriptPrefilter { + set: Option, + lock_to_bit: Vec<(usize, u32)>, +} + +impl ScriptPrefilter { + fn build() -> Self { + let table: &[(&'static OnceLock, &'static str)] = &[ + (&RE_CP_REQUIRE, PAT_CP_REQUIRE), + (&RE_CP_EXEC, PAT_CP_EXEC), + (&RE_CP_SPAWN, PAT_CP_SPAWN), + (&RE_CP_EXECSYNC, PAT_CP_EXECSYNC), + (&RE_VM_RUNNEWCTX, PAT_VM_RUNNEWCTX), + (&RE_DYN_REQUIRE, PAT_DYN_REQUIRE), + (&RE_DYN_IMPORT, PAT_DYN_IMPORT), + (&RE_EVAL, PAT_EVAL), + (&RE_FUNCTION_CTOR, PAT_FUNCTION_CTOR), + (&RE_EVAL_ATOB, PAT_EVAL_ATOB), + (&RE_BUF_B64_EVAL, PAT_BUF_B64_EVAL), + (&RE_FROMCHARCODE, PAT_FROMCHARCODE), + (&RE_HEX_STR_CHAIN, PAT_HEX_STR_CHAIN), + (&RE_0X_IDENTS, PAT_0X_IDENTS), + (&RE_SSH_KEY, PAT_SSH_KEY), + (&RE_AWS_CREDS, PAT_AWS_CREDS), + (&RE_NPMRC, PAT_NPMRC), + (&RE_DOTENV_READ, PAT_DOTENV_READ), + (&RE_PROCESS_ENV_ENUM, PAT_PROCESS_ENV_ENUM), + (&RE_KEYTAR, PAT_KEYTAR), + (&RE_BROWSER_CRED, PAT_BROWSER_CRED), + (&RE_SECRET_KEY_LITERAL, PAT_SECRET_KEY_LITERAL), + (&RE_CRYPTO_WALLET_STORE, PAT_CRYPTO_WALLET_STORE), + (&RE_GEM_CREDENTIALS, PAT_GEM_CREDENTIALS), + (&RE_FETCH_XHR, PAT_FETCH_XHR), + (&RE_HTTP_REQUEST, PAT_HTTP_REQUEST), + (&RE_CURL_CMD, PAT_CURL_CMD), + (&RE_WGET_CMD, PAT_WGET_CMD), + (&RE_PYTHON_REQUESTS, PAT_PYTHON_REQUESTS), + (&RE_PYTHON_URLLIB, PAT_PYTHON_URLLIB), + (&RE_SOCKET_CONNECT, PAT_SOCKET_CONNECT), + (&RE_CRONTAB, PAT_CRONTAB), + (&RE_BASHRC_WRITE, PAT_BASHRC_WRITE), + (&RE_SYSTEMD_WRITE, PAT_SYSTEMD_WRITE), + (&RE_WIN_RUNKEY, PAT_WIN_RUNKEY), + (&RE_REVSHELL_BASH, PAT_REVSHELL_BASH), + (&RE_REVSHELL_PYTHON, PAT_REVSHELL_PYTHON), + (&RE_REVSHELL_PERL, PAT_REVSHELL_PERL), + (&RE_NC_E, PAT_NC_E), + (&RE_SOCAT_EXEC, PAT_SOCAT_EXEC), + (&RE_CURL_BASH, PAT_CURL_BASH), + (&RE_WGET_BASH, PAT_WGET_BASH), + (&RE_DOWNLOAD_EXEC_JS, PAT_DOWNLOAD_EXEC_JS), + (&RE_POWERSHELL_IEX, PAT_POWERSHELL_IEX), + (&RE_PYTHON_EXEC_DOWNLOAD, PAT_PYTHON_EXEC_DOWNLOAD), + (&RE_PY_OS_SYSTEM, PAT_PY_OS_SYSTEM), + (&RE_PY_SUBPROCESS, PAT_PY_SUBPROCESS), + (&RE_PY_EVAL, PAT_PY_EVAL), + (&RE_PY_EXEC, PAT_PY_EXEC), + (&RE_SH_EVAL, PAT_SH_EVAL), + (&RE_SH_BASE64_PIPE, PAT_SH_BASE64_PIPE), + (&RE_PS_ENCODED_CMD, PAT_PS_ENCODED_CMD), + (&RE_PS_DOWNLOAD_STRING, PAT_PS_DOWNLOAD_STRING), + (&RE_PS_BYPASS, PAT_PS_BYPASS), + (&RE_RUBY_SYSTEM, PAT_RUBY_SYSTEM), + (&RE_RUBY_BACKTICK, PAT_RUBY_BACKTICK), + (&RE_RUBY_EVAL, PAT_RUBY_EVAL), + (&RE_PHP_EXEC, PAT_PHP_EXEC), + (&RE_PHP_EVAL, PAT_PHP_EVAL), + (&RE_PHP_BASE64, PAT_PHP_BASE64), + (&RE_PHP_PREG_REPLACE_E, PAT_PHP_PREG_REPLACE_E), + (&RE_NPMRC_AUTH_TOKEN, PAT_NPMRC_AUTH_TOKEN), + (&RE_AI_CLI_COERCION, PAT_AI_CLI_COERCION), + (&RE_GIT_TEMPLATE_DIR, PAT_GIT_TEMPLATE_DIR), + (&RE_GITHUB_TOKEN_PREFIX_CHECK, PAT_GITHUB_TOKEN_PREFIX_CHECK), + (&RE_ENV_CRED_PATTERN_SWEEP, PAT_ENV_CRED_PATTERN_SWEEP), + (&RE_GEOFENCE_GEO_API, PAT_GEOFENCE_GEO_API), + (&RE_GEOFENCE_TIMEZONE, PAT_GEOFENCE_TIMEZONE), + (&RE_SANDBOX_EVASION_CI, PAT_SANDBOX_EVASION_CI), + (&RE_SANDBOX_EVASION_USERNAME, PAT_SANDBOX_EVASION_USERNAME), + (&RE_STRING_FRAGMENT_REQUIRE, PAT_STRING_FRAGMENT_REQUIRE), + (&RE_DOUBLE_ENCODED_C2, PAT_DOUBLE_ENCODED_C2), + (&RE_PTH_OS_BRANCH_EXEC, PAT_PTH_OS_BRANCH_EXEC), + (&RE_EVAL_BUF_B64_OUTER, PAT_EVAL_BUF_B64_OUTER), + ]; + + let mut exprs: Vec<&'static str> = Vec::with_capacity(table.len()); + let mut lock_to_bit: Vec<(usize, u32)> = Vec::with_capacity(table.len()); + for (idx, &(lock, pat)) in table.iter().enumerate() { + let valid_pat = if Regex::new(pat).is_ok() { + pat + } else { + r"[^\s\S]" + }; + exprs.push(valid_pat); + lock_to_bit.push((std::ptr::from_ref(lock) as usize, idx as u32)); + } + lock_to_bit.sort_unstable_by_key(|&(addr, _)| addr); + + let set = RegexSetBuilder::new(&exprs) + .multi_line(true) + .size_limit(64 * 1024 * 1024) + .dfa_size_limit(64 * 1024 * 1024) + .build() + .ok(); + + Self { set, lock_to_bit } + } + + fn bit_for(&self, lock: &'static OnceLock) -> Option { + let addr = std::ptr::from_ref(lock) as usize; + self.lock_to_bit + .binary_search_by_key(&addr, |&(k, _)| k) + .ok() + .and_then(|pos| self.lock_to_bit.get(pos).map(|&(_, bit)| bit)) + } + + fn compute_mask(&self, text: &str, folded_extra: &str) -> u128 { + let Some(ref set) = self.set else { + return u128::MAX; + }; + let mut mask = 0_u128; + for idx in set.matches(text) { + if idx < 128 { + mask |= 1_u128 << idx; + } + } + if !folded_extra.is_empty() { + for idx in set.matches(folded_extra) { + if idx < 128 { + mask |= 1_u128 << idx; + } + } + } + mask + } +} + +fn script_prefilter() -> &'static ScriptPrefilter { + static PREFILTER: OnceLock = OnceLock::new(); + PREFILTER.get_or_init(ScriptPrefilter::build) +} + +/// True when any physical line ends with `\` or leaves `(`/`[` unclosed across +/// a newline, requiring `logical_lines` folding before prefiltering. +fn has_cross_line_continuation(text: &str) -> bool { + for line in text.lines() { + if line.ends_with('\\') || bracket_delta(line) > 0 { + return true; + } + } + false +} + // ───────────────────────────────────────────────────────────────────────────── // ScriptBehaviorEngine // ───────────────────────────────────────────────────────────────────────────── @@ -526,11 +709,31 @@ impl ScriptBehaviorEngine { let path = &file.rel_path; let kind = file.kind; - // We do a line-indexed scan so we can report line numbers. Physical - // lines are folded into logical lines (see `logical_lines`) so a call - // wrapped across several physical lines — a common formatter/obfuscation - // evasion — is still matched by the per-line rules below. - let lines: Vec<(usize, String)> = logical_lines(&text); + // Fast path: when no physical line folds onto the next, prefilter + // `text` directly before allocating `logical_lines`. + let prefilter = script_prefilter(); + let (lines, active_mask): (Vec<(usize, String)>, u128) = + if !has_cross_line_continuation(&text) { + let mask = prefilter.compute_mask(&text, ""); + if mask == 0 { + return; + } + (logical_lines(&text), mask) + } else { + let folded = logical_lines(&text); + let mut joined_folded = String::new(); + for (_, line) in &folded { + joined_folded.push_str(line); + joined_folded.push('\n'); + } + let mask = prefilter.compute_mask(&text, &joined_folded); + if mask == 0 { + return; + } + (folded, mask) + }; + + let _mask_guard = MaskGuard::set(active_mask); // --- Capability flags accumulated for chain detection --- let mut has_cred_read = false; diff --git a/crates/cleanton-antivirus/src/extract.rs b/crates/cleanton-antivirus/src/extract.rs index 0198dca..11fad6e 100644 --- a/crates/cleanton-antivirus/src/extract.rs +++ b/crates/cleanton-antivirus/src/extract.rs @@ -67,10 +67,7 @@ impl Loader { } else { let data = read_bounded_file(target, cfg, &mut state.errors)?; if let Some(data) = data { - let name = target - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| "file".to_string()); + let name = single_file_rel_path(target); state.ingest_bytes(&name, data, 0, cfg)?; } } @@ -88,6 +85,48 @@ impl Loader { } } +/// Derive a relative display path for a single-file scan target. Preserves +/// persistence-relevant parent directory segments (`Library/LaunchAgents/...`, +/// `.config/systemd/user/...`, `.config/autostart/...`, `.config/fish/...`, +/// `etc/cron.d/...`, etc.) so `PersistenceEngine` can classify single-file +/// targets accurately while ordinary files keep their basename. +fn single_file_rel_path(target: &Path) -> String { + let parts: Vec = target + .components() + .filter_map(|c| match c { + Component::Normal(s) => Some(s.to_string_lossy().into_owned()), + _ => None, + }) + .collect(); + if parts.len() >= 2 { + let joined_lower = parts.join("/").to_ascii_lowercase(); + for marker in [ + "library/launchagents/", + "library/launchdaemons/", + ".config/systemd/user/", + "systemd/user/", + ".config/autostart/", + "autostart/", + ".config/fish/", + "fish/", + "etc/profile.d/", + "etc/cron", + "var/spool/cron/", + ] { + if let Some(pos) = joined_lower.find(marker) { + let full = parts.join("/"); + if let Some(rel) = full.get(pos..) { + return rel.to_owned(); + } + } + } + } + target + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "file".to_string()) +} + // ── internal bookkeeping ────────────────────────────────────────────────────── struct LoadState { diff --git a/crates/cleanton-antivirus/src/scanner.rs b/crates/cleanton-antivirus/src/scanner.rs index 33751ed..0d73e69 100644 --- a/crates/cleanton-antivirus/src/scanner.rs +++ b/crates/cleanton-antivirus/src/scanner.rs @@ -125,29 +125,89 @@ impl Scanner { loaded: crate::extract::LoadedTarget, wall: Instant, ) -> AvResult { - let mut all_findings = Vec::new(); - let mut engine_stats = Vec::new(); - let pkg_ref = loaded.package_json.as_ref(); - - for engine in &self.engines { - let ctx = ScanContext { - target_name: target_name.clone(), - files: &loaded.files, - package_json: pkg_ref, - config: &self.config, + let target_ref = target_name.as_str(); + + // For large standalone targets (archives, monorepos), run engines in + // parallel on a bounded worker pool while preserving canonical engine + // ordering. Small per-package scans stay inline to avoid thread-spawn + // overhead when callers already parallelize across packages. + let results: Vec<(EngineStat, Vec)> = + if loaded.files.len() >= 64 && self.engines.len() >= 2 { + let next = std::sync::atomic::AtomicUsize::new(0); + let workers = std::thread::available_parallelism() + .map_or(2, std::num::NonZeroUsize::get) + .clamp(1, 4) + .min(self.engines.len()); + let slots: std::sync::Mutex< + Vec)>>, + > = std::sync::Mutex::new((0..self.engines.len()).map(|_| None).collect()); + + std::thread::scope(|scope| { + for _ in 0..workers { + scope.spawn(|| { + loop { + let idx = + next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let Some(engine) = self.engines.get(idx) else { + break; + }; + let ctx = ScanContext { + target_name: target_ref.to_owned(), + files: &loaded.files, + package_json: pkg_ref, + config: &self.config, + }; + let t0 = Instant::now(); + let findings = engine.scan(&ctx); + let stat = EngineStat { + engine: engine.name().to_owned(), + findings: findings.len(), + duration_ms: t0.elapsed().as_millis() as u64, + }; + let mut guard = match slots.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + if let Some(slot) = guard.get_mut(idx) { + *slot = Some((stat, findings)); + } + } + }); + } + }); + + let completed = match slots.into_inner() { + Ok(v) => v, + Err(p) => p.into_inner(), + }; + completed.into_iter().flatten().collect() + } else { + self.engines + .iter() + .map(|engine| { + let ctx = ScanContext { + target_name: target_ref.to_owned(), + files: &loaded.files, + package_json: pkg_ref, + config: &self.config, + }; + let t0 = Instant::now(); + let findings = engine.scan(&ctx); + let stat = EngineStat { + engine: engine.name().to_owned(), + findings: findings.len(), + duration_ms: t0.elapsed().as_millis() as u64, + }; + (stat, findings) + }) + .collect() }; - let t0 = Instant::now(); - let findings = engine.scan(&ctx); - let duration_ms = t0.elapsed().as_millis() as u64; - - engine_stats.push(EngineStat { - engine: engine.name().to_owned(), - findings: findings.len(), - duration_ms, - }); - + let mut all_findings = Vec::new(); + let mut engine_stats = Vec::with_capacity(results.len()); + for (stat, findings) in results { + engine_stats.push(stat); all_findings.extend(findings); } diff --git a/crates/cleanton-app/src/main.rs b/crates/cleanton-app/src/main.rs index 0754084..8e2a42c 100644 --- a/crates/cleanton-app/src/main.rs +++ b/crates/cleanton-app/src/main.rs @@ -15,7 +15,7 @@ use std::{ rc::Rc, sync::{ Arc, Mutex, - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicU64, Ordering}, }, }; @@ -28,10 +28,11 @@ use cleanton_common::{ ExecutionStatus, SafetyLevel, display, }; use cleanton_engine::{ - AnalyzedFull, AnalyzedPlan, AnnotatedAction, DockerAnalysis, DockerCleaner, DockerCleanupPlan, - DuplicateSet, EngineEvent, InstalledApp, LargeItem, Orchestrator, SecurityRemoval, - SnapshotAnalysis, SnapshotConfirmState, Suggestions, UninstallPlan, cask_token_for, - delete_local_snapshots, find_leftovers, format_snapshot_date, list_installed_apps, + AnalyzedFull, AnalyzedPlan, AnnotatedAction, CleanupOffer, DockerAnalysis, DockerCleaner, + DockerCleanupPlan, DuplicateSet, EngineEvent, InstalledApp, LargeItem, Orchestrator, + ResourceStatus, SecurityRemoval, SnapshotAnalysis, SnapshotConfirmState, Suggestions, + UninstallPlan, cask_token_for, delete_local_snapshots, find_leftovers, format_snapshot_date, + list_installed_apps, snapshot_resource_status, }; use cleanton_packages::{Inventory, ManagerKind, PackageManagers, plan_removal, scan_usage}; use cleanton_scanner::ScanOptions; @@ -89,6 +90,15 @@ static SECURITY_REMOVE_IN_FLIGHT: AtomicBool = AtomicBool::new(false); /// One uninstall at a time. static UNINSTALL_IN_FLIGHT: AtomicBool = AtomicBool::new(false); +/// Monotonic generation counter for package-tab loads so stale responses from +/// an earlier manager tab never overwrite the newly selected tab. +static PACKAGE_LOAD_ID: AtomicU64 = AtomicU64::new(0); + +/// User-dismissed resource pressure banners; automatically re-armed once usage +/// falls 5% below threshold so genuine new pressure spikes surface again. +static DISK_OFFER_DISMISSED: AtomicBool = AtomicBool::new(false); +static MEMORY_OFFER_DISMISSED: AtomicBool = AtomicBool::new(false); + /// App-bundle paths Cleanton itself just moved to the Trash. The background /// app-removal watcher consults this so it does not re-trigger the "an app was /// removed — clean its leftovers" deep-link for a removal Cleanton performed @@ -193,21 +203,16 @@ fn main() -> Result<(), slint::PlatformError> { slint::BackendSelector::new().select()?; register_embedded_fonts(); - // Create the Slint window. + // Create the Slint window and populate immediate in-memory state so the + // first frame renders without waiting on subprocesses (`tmutil`, `brew`, + // `npm`, `pip`, `port`) or disk/memory probes. let ui = AppWindow::new()?; initialise_app_state(&ui, &config); - // Read real disk capacity + usage history once at startup (no mock data). - apply_capacity_state( - &ui.global::(), - &capacity::read(&capacity_root(&config)), - ); - // Probe Full Disk Access once at startup. A single filesystem open is cheap - // and infallible (denial is a normal result), so it stays on the UI thread - // without affecting the background-thread scan/clean model. apply_fda_state(&ui.global::()); // The config becomes shared + mutable so the Settings screen can edit it and // subsequent scans/cleans observe the updated values. let shared_config: SharedConfig = Arc::new(Mutex::new(config)); + spawn_startup_probes(ui.as_weak(), shared_config.clone()); let latest_plan = Arc::new(Mutex::new(None::)); let current_operation = Arc::new(Mutex::new(None::)); @@ -284,8 +289,9 @@ fn main() -> Result<(), slint::PlatformError> { .await; progress_handle.abort(); - // Refresh real disk capacity + record a usage sample (off the UI thread). + // Refresh real disk capacity + resource status (off the UI thread). let capacity_view = capacity::read(&capacity_root(&config)); + let resource_status = read_resource_status(&config); let _ = ui_weak.upgrade_in_event_loop(move |ui| { if cancel_token.load(Ordering::Relaxed) { @@ -296,6 +302,7 @@ fn main() -> Result<(), slint::PlatformError> { let state = ui.global::(); state.set_scanning(false); apply_capacity_state(&state, &capacity_view); + apply_resource_status(&state, &resource_status); match result { Ok((scan, full)) => { store_latest_plan(&latest_plan, Some(full.analyzed.plan.clone())); @@ -567,8 +574,9 @@ fn main() -> Result<(), slint::PlatformError> { } .await; - // Refresh capacity so the dashboard reflects any bytes actually freed. + // Refresh capacity + resource status so the dashboard reflects freed space. let capacity_view = capacity::read(&capacity_root(&config)); + let resource_status = read_resource_status(&config); let _ = ui_weak.upgrade_in_event_loop(move |ui| { // Superseded by a newer operation: drop this result. A user @@ -584,6 +592,7 @@ fn main() -> Result<(), slint::PlatformError> { let state = ui.global::(); state.set_cleaning(false); apply_capacity_state(&state, &capacity_view); + apply_resource_status(&state, &resource_status); match result { Ok(result) => { apply_execution_result(&state, &result); @@ -663,11 +672,13 @@ fn main() -> Result<(), slint::PlatformError> { let config = config_snapshot(&config_clone); std::thread::spawn(move || { let result = trash_ops::empty_trash(); - // Re-read capacity so the dashboard reflects reclaimed space. + // Re-read capacity + resource status so the dashboard reflects reclaimed space. let capacity_view = capacity::read(&capacity_root(&config)); + let resource_status = read_resource_status(&config); let _ = ui_weak.upgrade_in_event_loop(move |ui| { let state = ui.global::(); apply_capacity_state(&state, &capacity_view); + apply_resource_status(&state, &resource_status); refresh_trash_state(&state); match result { Ok(()) => state.set_trash_status( @@ -681,6 +692,68 @@ fn main() -> Result<(), slint::PlatformError> { }); }); + // ── Wire: Memory reclaim + resource pressure callbacks ───────────────── + let ui_weak = ui.as_weak(); + let config_clone = shared_config.clone(); + ui.global::().on_reclaim_memory(move || { + let Some(ui) = ui_weak.upgrade() else { + return; + }; + let state = ui.global::(); + state.set_memory_status( + "Reclaiming unused allocator pages and refreshing memory...".into(), + ); + + let ui_weak = ui.as_weak(); + let config = config_snapshot(&config_clone); + std::thread::spawn(move || { + let reclaimed = cleanton_scanner::reclaim_process_memory(); + let status = read_resource_status(&config); + let _ = ui_weak.upgrade_in_event_loop(move |ui| { + let state = ui.global::(); + apply_resource_status(&state, &status); + let note = if reclaimed { + "Reclaimed idle allocator pages. Review heavy processes or background services if memory stays elevated." + } else { + "Memory snapshot refreshed. Review heavy processes or background services to free more RAM." + }; + state.set_memory_status(note.into()); + }); + }); + }); + + let ui_weak = ui.as_weak(); + let config_clone = shared_config.clone(); + ui.global::().on_refresh_memory(move || { + let ui_weak = ui_weak.clone(); + let config = config_snapshot(&config_clone); + std::thread::spawn(move || { + let capacity_view = capacity::read(&capacity_root(&config)); + let status = read_resource_status(&config); + let _ = ui_weak.upgrade_in_event_loop(move |ui| { + let state = ui.global::(); + apply_capacity_state(&state, &capacity_view); + apply_resource_status(&state, &status); + }); + }); + }); + + let ui_weak = ui.as_weak(); + ui.global::().on_dismiss_disk_offer(move || { + DISK_OFFER_DISMISSED.store(true, Ordering::Relaxed); + if let Some(ui) = ui_weak.upgrade() { + ui.global::().set_disk_offer_active(false); + } + }); + + let ui_weak = ui.as_weak(); + ui.global::().on_dismiss_memory_offer(move || { + MEMORY_OFFER_DISMISSED.store(true, Ordering::Relaxed); + if let Some(ui) = ui_weak.upgrade() { + ui.global::().set_memory_offer_active(false); + } + }); + // ── Wire: Services refresh callback ──────────────────────────────────── let ui_weak = ui.as_weak(); let config_clone = shared_config.clone(); @@ -1271,6 +1344,17 @@ fn main() -> Result<(), slint::PlatformError> { state.set_setting_save_status(format!("Save failed: {err}").into()); } } + + // Re-evaluate pressure offers immediately against the updated thresholds. + DISK_OFFER_DISMISSED.store(false, Ordering::Relaxed); + MEMORY_OFFER_DISMISSED.store(false, Ordering::Relaxed); + let ui_weak = ui.as_weak(); + std::thread::spawn(move || { + let status = read_resource_status(&updated); + let _ = ui_weak.upgrade_in_event_loop(move |ui| { + apply_resource_status(&ui.global::(), &status); + }); + }); }); // ── Wire: Reset settings callback ────────────────────────────────────── @@ -1588,16 +1672,180 @@ fn main() -> Result<(), slint::PlatformError> { } }); - // ── Watch for apps removed outside Cleanton ──────────────────────────── - // A light background poll of the Applications folders. When the user drags - // an app to the Trash (or another uninstaller removes it), we deep-link to - // the Applications tab and load its leftovers so they can be cleaned up. + // ── Watch for apps removed outside Cleanton + resource pressure ─────── + // Light background polls for externally-removed apps and high disk/memory + // usage so cleanup offers surface automatically even while idle. spawn_app_removal_watcher(ui.as_weak()); + spawn_resource_pressure_watcher(ui.as_weak(), shared_config.clone()); // ── Run the event loop ───────────────────────────────────────────────── ui.run() } +/// Offload startup probes (`PackageManagers::detect`, `capacity::read`, +/// `snapshot_resource_status`, and Trash check) from `main()` so the window +/// paints immediately. +fn spawn_startup_probes(ui_weak: slint::Weak, shared_config: SharedConfig) { + std::thread::spawn(move || { + let config = config_snapshot(&shared_config); + let has_trash = trash_ops::trash_has_items(); + let detected = PackageManagers::detect(); + let capacity_view = capacity::read(&capacity_root(&config)); + let resource_status = read_resource_status(&config); + let _ = ui_weak.upgrade_in_event_loop(move |ui| { + let state = ui.global::(); + state.set_trash_has_items(has_trash); + if !has_trash && !state.get_empty_trash_pending() { + state.set_trash_status("Trash is empty: nothing to reclaim there.".into()); + } + apply_detected_package_tabs(&state, &detected); + apply_capacity_state(&state, &capacity_view); + apply_resource_status(&state, &resource_status); + }); + }); +} + +/// Periodic background monitor for high disk or memory usage. +fn spawn_resource_pressure_watcher(ui_weak: slint::Weak, shared_config: SharedConfig) { + std::thread::spawn(move || { + loop { + std::thread::sleep(Duration::from_secs(30)); + let config = config_snapshot(&shared_config); + let status = read_resource_status(&config); + if ui_weak + .upgrade_in_event_loop(move |ui| { + apply_resource_status(&ui.global::(), &status); + }) + .is_err() + { + break; + } + } + }); +} + +fn read_resource_status(config: &CleantonConfig) -> ResourceStatus { + snapshot_resource_status( + config.background.disk_pressure_percent, + config.background.memory_pressure_percent, + ) +} + +fn apply_resource_status(state: &AppState, status: &ResourceStatus) { + if let (Some(total), Some(available), Some(pct)) = ( + status.memory_total_bytes, + status.memory_available_bytes, + status.memory_used_percent, + ) { + let used = total.saturating_sub(available); + let ratio = if total > 0 { + (used as f64 / total as f64) as f32 + } else { + 0.0 + }; + state.set_memory_ready(true); + state.set_memory_total(display::human_bytes(total).into()); + state.set_memory_used(format!("{} ({pct}%)", display::human_bytes(used)).into()); + state.set_memory_available(display::human_bytes(available).into()); + state.set_memory_used_ratio(ratio.clamp(0.0, 1.0)); + } else { + state.set_memory_ready(false); + state.set_memory_total("--".into()); + state.set_memory_used("Unavailable".into()); + state.set_memory_available("--".into()); + state.set_memory_used_ratio(0.0); + } + + // Re-arm dismissed banners once usage drops comfortably below threshold. + if status.disk_offer.is_none() { + DISK_OFFER_DISMISSED.store(false, Ordering::Relaxed); + } + if status.memory_offer.is_none() { + MEMORY_OFFER_DISMISSED.store(false, Ordering::Relaxed); + } + + if let Some(offer) = &status.disk_offer + && !DISK_OFFER_DISMISSED.load(Ordering::Relaxed) + { + let pct = status.disk_used_percent.unwrap_or(0); + let avail = status + .disk_available_bytes + .map(display::human_bytes) + .unwrap_or_else(|| "--".to_owned()); + state.set_disk_offer_headline( + format!("Disk usage is high ({pct}% used, {avail} free) - disk cleanup recommended") + .into(), + ); + let detail = match offer { + CleanupOffer::Disk { + reclaimable_bytes, + candidates, + recommended_action, + } if *reclaimable_bytes > 0 => { + let top = candidates + .iter() + .take(3) + .map(|c| format!("{} ({})", c.label, display::human_bytes(c.bytes))) + .collect::>() + .join(", "); + format!( + "Detected ~{} in quick-relief targets: {}. {}", + display::human_bytes(*reclaimable_bytes), + top, + recommended_action + ) + } + CleanupOffer::Disk { + recommended_action, .. + } => recommended_action.clone(), + CleanupOffer::Memory { .. } => String::new(), + }; + state.set_disk_offer_detail(detail.into()); + state.set_disk_offer_active(true); + } else { + state.set_disk_offer_active(false); + } + + if let Some(offer) = &status.memory_offer + && !MEMORY_OFFER_DISMISSED.load(Ordering::Relaxed) + { + let pct = status.memory_used_percent.unwrap_or(0); + let avail = status + .memory_available_bytes + .map(display::human_bytes) + .unwrap_or_else(|| "--".to_owned()); + state.set_memory_offer_headline( + format!( + "Memory usage is high ({pct}% used, {avail} available) - memory cleanup recommended" + ) + .into(), + ); + let detail = match offer { + CleanupOffer::Memory { + top_processes, + recommended_action, + .. + } if !top_processes.is_empty() => { + let top = top_processes + .iter() + .take(4) + .map(|p| format!("{} ({})", p.name, display::human_bytes(p.rss_bytes))) + .collect::>() + .join(", "); + format!("Top resident processes: {top}. {recommended_action}") + } + CleanupOffer::Memory { + recommended_action, .. + } => recommended_action.clone(), + CleanupOffer::Disk { .. } => String::new(), + }; + state.set_memory_offer_detail(detail.into()); + state.set_memory_offer_active(true); + } else { + state.set_memory_offer_active(false); + } +} + /// Background thread that polls for externally-removed apps and, on detection, /// deep-links the UI to review their leftovers. Runs for the app's lifetime. fn spawn_app_removal_watcher(ui_weak: slint::Weak) { @@ -1699,6 +1947,7 @@ fn start_package_load(ui: &AppWindow, idx: i32, config: &SharedConfig) { PKG_INVENTORY.with(|cell| *cell.borrow_mut() = None); PKG_KEEP.with(|cell| cell.borrow_mut().clear()); + let load_id = PACKAGE_LOAD_ID.fetch_add(1, Ordering::SeqCst) + 1; let ui_weak = ui.as_weak(); let roots = package_scan_roots(&config_snapshot(config)); std::thread::spawn(move || { @@ -1706,6 +1955,9 @@ fn start_package_load(ui: &AppWindow, idx: i32, config: &SharedConfig) { // Phase 1 — read the inventory and SHOW it immediately. The slow, // network-bound update check runs separately in phase 2. let inventory = PackageManagers::inventory(kind).await; + if PACKAGE_LOAD_ID.load(Ordering::SeqCst) != load_id { + return; + } let any = !inventory.packages.is_empty(); let do_usage = matches!(kind, ManagerKind::Homebrew) && any; let names: Vec = if do_usage { @@ -1714,6 +1966,9 @@ fn start_package_load(ui: &AppWindow, idx: i32, config: &SharedConfig) { Vec::new() }; let _ = ui_weak.upgrade_in_event_loop(move |ui| { + if PACKAGE_LOAD_ID.load(Ordering::SeqCst) != load_id { + return; + } let state = ui.global::(); state.set_package_loading(false); apply_inventory(&state, inventory); @@ -1739,12 +1994,18 @@ fn start_package_load(ui: &AppWindow, idx: i32, config: &SharedConfig) { // Phase 2 — slow probes: update check (every manager) plus the // codebase-usage scan (Homebrew only). Fill the list in progressively. let outdated = PackageManagers::outdated(kind).await; + if PACKAGE_LOAD_ID.load(Ordering::SeqCst) != load_id { + return; + } let usage = if do_usage { scan_usage(&names, &roots) } else { HashMap::new() }; let _ = ui_weak.upgrade_in_event_loop(move |ui| { + if PACKAGE_LOAD_ID.load(Ordering::SeqCst) != load_id { + return; + } let state = ui.global::(); let update_count = apply_outdated(&state, &outdated); if do_usage { @@ -2307,13 +2568,12 @@ fn refresh_trash_state(state: &AppState) { let has_items = trash_ops::trash_has_items(); state.set_trash_has_items(has_items); if !has_items && !state.get_empty_trash_pending() { - state.set_trash_status("Trash is empty \u{2014} nothing to reclaim there.".into()); + state.set_trash_status("Trash is empty: nothing to reclaim there.".into()); } } fn initialise_app_state(ui: &AppWindow, config: &CleantonConfig) { let state = ui.global::(); - refresh_trash_state(&state); // Version of the build actually running (Settings > About). state.set_app_version(SharedString::from(env!("CARGO_PKG_VERSION"))); @@ -2324,23 +2584,37 @@ fn initialise_app_state(ui: &AppWindow, config: &CleantonConfig) { Vec::::new(), )))); - // Populate the package-manager selector (all five, greyed when not installed). - let detected = PackageManagers::detect(); + // Populate initial package-manager tabs synchronously without spawning + // subprocesses; `spawn_startup_probes` updates availability off the UI thread. let tabs: Vec = PKG_MANAGER_ORDER .iter() .map(|kind| PkgManagerTab { id: SharedString::from(kind.id()), label: SharedString::from(kind.label()), - available: detected.contains(kind), + available: true, }) .collect(); - // Default the active tab to the first available manager. - let first_available = PKG_MANAGER_ORDER + state.set_active_package_tab(0); + state.set_package_tabs(ModelRc::from(Rc::new(VecModel::from(tabs)))); +} + +fn apply_detected_package_tabs(state: &AppState, detected: &[ManagerKind]) { + let tabs: Vec = PKG_MANAGER_ORDER .iter() - .position(|kind| detected.contains(kind)) - .unwrap_or(0); - state.set_active_package_tab(clamp_to_i32(first_available)); + .map(|kind| PkgManagerTab { + id: SharedString::from(kind.id()), + label: SharedString::from(kind.label()), + available: detected.contains(kind), + }) + .collect(); state.set_package_tabs(ModelRc::from(Rc::new(VecModel::from(tabs)))); + if state.get_package_count() == 0 && !state.get_package_loading() { + let first_available = PKG_MANAGER_ORDER + .iter() + .position(|kind| detected.contains(kind)) + .unwrap_or(0); + state.set_active_package_tab(clamp_to_i32(first_available)); + } } /// Read the current config snapshot, recovering the inner value if the lock was @@ -2381,6 +2655,12 @@ fn populate_settings(state: &AppState, config: &CleantonConfig) { state.set_setting_max_action_gb( i32::try_from(config.safety.max_single_action_gb).unwrap_or(i32::MAX), ); + state.set_setting_disk_pressure_percent( + i32::from(config.background.disk_pressure_percent.unwrap_or(0)), + ); + state.set_setting_memory_pressure_percent( + i32::from(config.background.memory_pressure_percent.unwrap_or(0)), + ); } /// Read the edited Settings controls back into `config`. @@ -2403,6 +2683,18 @@ fn read_settings_into_config(state: &AppState, config: &mut CleantonConfig) { .unwrap_or(config.safety.max_single_run_gb); config.safety.max_single_action_gb = u64::try_from(state.get_setting_max_action_gb().max(1)) .unwrap_or(config.safety.max_single_action_gb); + let disk_pct = state.get_setting_disk_pressure_percent().clamp(0, 99); + config.background.disk_pressure_percent = if disk_pct == 0 { + None + } else { + u8::try_from(disk_pct).ok() + }; + let mem_pct = state.get_setting_memory_pressure_percent().clamp(0, 99); + config.background.memory_pressure_percent = if mem_pct == 0 { + None + } else { + u8::try_from(mem_pct).ok() + }; } fn clamp_to_i32(value: usize) -> i32 { @@ -3786,6 +4078,7 @@ fn service_type_label(service_type: ServiceType) -> &'static str { ServiceType::SystemLaunchAgent => "System agent", ServiceType::SystemLaunchDaemon => "Daemon", ServiceType::BrewService => "Brew", + ServiceType::SystemdUserService => "systemd", } } diff --git a/crates/cleanton-app/ui/app.slint b/crates/cleanton-app/ui/app.slint index 9c158e8..bc66c16 100644 --- a/crates/cleanton-app/ui/app.slint +++ b/crates/cleanton-app/ui/app.slint @@ -323,6 +323,21 @@ export global AppState { in-out property <[float]> usage-history; in-out property usage-history-ready: false; + // System memory readout + background-helper cleanup offers when disk or + // memory usage exceeds configured thresholds. + in-out property memory-ready: false; + in-out property memory-total: "--"; + in-out property memory-used: "--"; + in-out property memory-available: "--"; + in-out property memory-used-ratio: 0.0; + in-out property memory-status: ""; + in-out property disk-offer-active: false; + in-out property disk-offer-headline: ""; + in-out property disk-offer-detail: ""; + in-out property memory-offer-active: false; + in-out property memory-offer-headline: ""; + in-out property memory-offer-detail: ""; + // Empty Trash affordance (Trash is not freed until emptied). in-out property empty-trash-pending: false; // True only when the Trash actually holds items. The "Empty Trash now" @@ -411,6 +426,8 @@ export global AppState { in-out property setting-ml: false; in-out property setting-max-run-gb: 250; in-out property setting-max-action-gb: 100; + in-out property setting-disk-pressure-percent: 85; + in-out property setting-memory-pressure-percent: 85; in-out property setting-save-status: "No changes saved yet."; // Package version of the running build, filled from CARGO_PKG_VERSION. in-out property app-version: ""; @@ -506,6 +523,11 @@ export global AppCallbacks { callback confirm-uninstall(); callback request-empty-trash-confirmation(); callback empty-trash(); + // Reclaim idle process/allocator memory and refresh live memory status. + callback reclaim-memory(); + callback refresh-memory(); + callback dismiss-disk-offer(); + callback dismiss-memory-offer(); // Opens System Settings → Privacy & Security → Full Disk Access. Gated // behind an explicit user click. callback open-fda-settings(); @@ -628,9 +650,39 @@ export component AppWindow inherits Window { SidebarItem { label: "Dashboard"; icon-glyph: "\u{f200}"; active: AppState.active-screen == 0; clicked => { AppState.active-screen = 0; } } SidebarItem { label: "Results"; icon-glyph: "\u{f0ae}"; active: AppState.active-screen == 1; clicked => { AppState.active-screen = 1; } } - SidebarItem { label: "Packages"; icon-glyph: "\u{f1b3}"; active: AppState.active-screen == 2; clicked => { AppState.active-screen = 2; AppCallbacks.refresh-packages(); } } - SidebarItem { label: "Services"; icon-glyph: "\u{f233}"; active: AppState.active-screen == 3; clicked => { AppState.active-screen = 3; AppCallbacks.refresh-services(); } } - SidebarItem { label: "Applications"; icon-glyph: "\u{e196}"; active: AppState.active-screen == 5; clicked => { AppState.active-screen = 5; AppCallbacks.refresh-apps(); } } + SidebarItem { + label: "Packages"; + icon-glyph: "\u{f1b3}"; + active: AppState.active-screen == 2; + clicked => { + AppState.active-screen = 2; + if AppState.package-count == 0 && !AppState.package-loading && AppState.package-status == "Not loaded" { + AppCallbacks.refresh-packages(); + } + } + } + SidebarItem { + label: "Services"; + icon-glyph: "\u{f233}"; + active: AppState.active-screen == 3; + clicked => { + AppState.active-screen = 3; + if AppState.service-count == 0 && !AppState.loading-services && AppState.services-status == "Not loaded" { + AppCallbacks.refresh-services(); + } + } + } + SidebarItem { + label: "Applications"; + icon-glyph: "\u{e196}"; + active: AppState.active-screen == 5; + clicked => { + AppState.active-screen = 5; + if AppState.app-rows.length == 0 && !AppState.apps-loading && AppState.apps-status == "" { + AppCallbacks.refresh-apps(); + } + } + } SidebarItem { label: "Security"; icon-glyph: "\u{f132}"; active: AppState.active-screen == 6; clicked => { AppState.active-screen = 6; } } SidebarItem { label: "Settings"; icon-glyph: "\u{f013}"; active: AppState.active-screen == 4; clicked => { AppState.active-screen = 4; } } @@ -733,6 +785,93 @@ export component AppWindow inherits Window { VerticalLayout { spacing: Theme.spacing-lg; + // Background-helper high disk usage offer. + if AppState.disk-offer-active: Panel { + border-color: Theme.palette.warning.with-alpha(0.55); + + VerticalLayout { + padding: Theme.spacing-lg; + spacing: Theme.spacing-sm; + + HorizontalLayout { + spacing: Theme.spacing-sm; + cross-axis-alignment: center; + FaIcon { glyph: "\u{f071}"; icon-color: Theme.palette.warning; icon-size: Theme.font-size-md; width: 22px; } + Text { text: AppState.disk-offer-headline; color: Theme.palette.warning; font-size: Theme.font-size-md; font-weight: 700; horizontal-stretch: 1; wrap: word-wrap; } + ActionButton { + label: AppState.scanning ? "Scanning..." : "Scan & Review"; + icon-glyph: "\u{f002}"; + button-color: Theme.palette.warning; + enabled: !AppState.scanning && !AppState.cleaning; + clicked => { + AppCallbacks.start-scan(); + AppState.active-screen = 1; + } + } + ActionButton { + label: "Dismiss"; + primary: false; + min-width: 88px; + clicked => { AppCallbacks.dismiss-disk-offer(); } + } + } + + if AppState.disk-offer-detail != "": Text { + text: AppState.disk-offer-detail; + color: Theme.palette.text-secondary; + font-size: Theme.font-size-sm; + wrap: word-wrap; + } + } + } + + // Background-helper high memory usage offer. + if AppState.memory-offer-active: Panel { + border-color: Theme.palette.warning.with-alpha(0.55); + + VerticalLayout { + padding: Theme.spacing-lg; + spacing: Theme.spacing-sm; + + HorizontalLayout { + spacing: Theme.spacing-sm; + cross-axis-alignment: center; + FaIcon { glyph: "\u{e1ec}"; icon-color: Theme.palette.warning; icon-size: Theme.font-size-md; width: 22px; } + Text { text: AppState.memory-offer-headline; color: Theme.palette.warning; font-size: Theme.font-size-md; font-weight: 700; horizontal-stretch: 1; wrap: word-wrap; } + ActionButton { + label: "Reclaim Memory"; + icon-glyph: "\u{f51a}"; + button-color: Theme.palette.warning; + clicked => { AppCallbacks.reclaim-memory(); } + } + ActionButton { + label: "Review Services"; + icon-glyph: "\u{f233}"; + primary: false; + clicked => { + AppState.active-screen = 3; + if AppState.service-count == 0 && !AppState.loading-services { + AppCallbacks.refresh-services(); + } + } + } + ActionButton { + label: "Dismiss"; + primary: false; + min-width: 88px; + clicked => { AppCallbacks.dismiss-memory-offer(); } + } + } + + if AppState.memory-offer-detail != "": Text { + text: AppState.memory-offer-detail; + color: Theme.palette.text-secondary; + font-size: Theme.font-size-sm; + wrap: word-wrap; + } + } + } + if AppState.fda-needs-attention: Panel { VerticalLayout { padding: Theme.spacing-lg; @@ -933,6 +1072,51 @@ export component AppWindow inherits Window { } } } + + Panel { + VerticalLayout { + padding: Theme.spacing-lg; + spacing: Theme.spacing-md; + + HorizontalLayout { + spacing: Theme.spacing-sm; + cross-axis-alignment: center; + FaIcon { glyph: "\u{e1ec}"; icon-color: Theme.palette.accent; icon-size: Theme.font-size-md; width: 22px; vertical-alignment: center; } + Text { text: "System Memory"; color: Theme.palette.text-primary; font-size: Theme.font-size-lg; font-weight: 700; horizontal-stretch: 1; vertical-alignment: center; } + Text { text: AppState.memory-ready ? "" : "Unavailable"; color: Theme.palette.warning; font-size: Theme.font-size-sm; vertical-alignment: center; } + ActionButton { + label: "Reclaim Memory"; + icon-glyph: "\u{f51a}"; + primary: false; + clicked => { AppCallbacks.reclaim-memory(); } + } + ActionButton { + label: "Refresh"; + icon-glyph: "\u{f021}"; + primary: false; + min-width: 96px; + clicked => { AppCallbacks.refresh-memory(); } + } + } + + HorizontalLayout { + spacing: Theme.spacing-xl; + VerticalLayout { spacing: Theme.spacing-xs; Text { text: "Total"; color: Theme.palette.text-muted; font-size: Theme.font-size-xs; } Text { text: AppState.memory-total; color: Theme.palette.text-primary; font-size: Theme.font-size-lg; font-weight: 700; } } + VerticalLayout { spacing: Theme.spacing-xs; Text { text: "Used"; color: Theme.palette.text-muted; font-size: Theme.font-size-xs; } Text { text: AppState.memory-used; color: Theme.palette.text-primary; font-size: Theme.font-size-lg; font-weight: 700; } } + VerticalLayout { spacing: Theme.spacing-xs; Text { text: "Available"; color: Theme.palette.text-muted; font-size: Theme.font-size-xs; } Text { text: AppState.memory-available; color: Theme.palette.success; font-size: Theme.font-size-lg; font-weight: 700; } } + Rectangle { horizontal-stretch: 1; } + } + + ProgressBar { progress: AppState.memory-used-ratio; } + + if AppState.memory-status != "": Text { + text: AppState.memory-status; + color: Theme.palette.text-muted; + font-size: Theme.font-size-xs; + wrap: word-wrap; + } + } + } } } } @@ -1091,8 +1275,16 @@ export component AppWindow inherits Window { alignment: center; spacing: Theme.spacing-sm; - FaIcon { - glyph: AppState.scanning || AppState.cleaning ? "\u{f110}" : "\u{f0ae}"; + if AppState.scanning || AppState.cleaning: HorizontalLayout { + alignment: center; + Spinner { + running: true; + spinner-size: 26px; + spinner-color: Theme.palette.text-muted; + } + } + if !AppState.scanning && !AppState.cleaning: FaIcon { + glyph: "\u{f0ae}"; icon-color: Theme.palette.text-muted; icon-size: Theme.font-size-xl; } @@ -1450,24 +1642,21 @@ export component AppWindow inherits Window { HorizontalLayout { spacing: Theme.spacing-sm; + cross-axis-alignment: center; VerticalLayout { spacing: Theme.spacing-xs; horizontal-stretch: 1; Text { text: "Packages"; color: Theme.palette.text-primary; font-size: Theme.font-size-xl; font-weight: 700; } Text { text: AppState.package-status; color: Theme.palette.text-secondary; font-size: Theme.font-size-sm; overflow: elide; } } - ActionButton { label: AppState.package-loading ? "Loading..." : "Refresh"; icon-glyph: "\u{f021}"; enabled: !AppState.package-loading && !AppState.package-acting; clicked => { AppCallbacks.refresh-packages(); } } - // Activity indicator on the right, next to the action that - // started it. Conditional rather than `visible: false`: a - // hidden element still occupies its slot in a layout, so an - // always-present spinner left a permanent gap after Refresh. - if AppState.package-loading || AppState.package-acting || AppState.package-usage-scanning: HorizontalLayout { - alignment: center; - Spinner { - running: true; - spinner-size: 16px; - } + // Fixed-width non-stretching spinner placed to the left of + // Refresh so the Refresh button stays anchored at the right + // edge both during and after loading. + if AppState.package-loading || AppState.package-acting || AppState.package-usage-scanning: Spinner { + running: true; + spinner-size: 16px; } + ActionButton { label: AppState.package-loading ? "Loading..." : "Refresh"; icon-glyph: "\u{f021}"; enabled: !AppState.package-loading && !AppState.package-acting; clicked => { AppCallbacks.refresh-packages(); } } } // Indeterminate activity strip: feedback that work is in flight, @@ -1527,12 +1716,10 @@ export component AppWindow inherits Window { if AppState.package-action-status != "": HorizontalLayout { spacing: Theme.spacing-xs; - if AppState.package-usage-scanning || AppState.package-acting || AppState.package-loading: HorizontalLayout { - alignment: center; - Spinner { - running: true; - spinner-size: 12px; - } + cross-axis-alignment: center; + if AppState.package-usage-scanning || AppState.package-acting || AppState.package-loading: Spinner { + running: true; + spinner-size: 12px; } Text { text: AppState.package-action-status; @@ -1546,10 +1733,6 @@ export component AppWindow inherits Window { if AppState.package-note != "": Text { text: AppState.package-note; color: Theme.palette.warning; font-size: Theme.font-size-xs; wrap: word-wrap; } - if AppState.package-acting || AppState.package-loading: ActivityBar { - running: true; - } - HorizontalLayout { spacing: Theme.spacing-md; alignment: end; @@ -1671,24 +1854,36 @@ export component AppWindow inherits Window { spacing: Theme.spacing-md; HorizontalLayout { + spacing: Theme.spacing-sm; + cross-axis-alignment: center; + VerticalLayout { spacing: Theme.spacing-xs; horizontal-stretch: 1; Text { text: "Services"; color: Theme.palette.text-primary; font-size: Theme.font-size-xl; font-weight: 700; } - Text { text: AppState.services-status; color: Theme.palette.text-secondary; font-size: Theme.font-size-sm; } + Text { text: AppState.services-status; color: Theme.palette.text-secondary; font-size: Theme.font-size-sm; overflow: elide; } + } + + if AppState.loading-services: Spinner { + running: true; + spinner-size: 16px; } ActionButton { - label: "Refresh"; + label: AppState.loading-services ? "Loading..." : "Refresh"; icon-glyph: "\u{f021}"; enabled: !AppState.loading-services; clicked => { AppCallbacks.refresh-services(); } } } + if AppState.loading-services: ActivityBar { + running: true; + } + HorizontalLayout { spacing: Theme.spacing-md; - MetricTile { label: "Discovered"; value: AppState.service-count; detail: "launchd and Homebrew"; icon-glyph: "\u{f233}"; accent-color: Theme.palette.info; ratio: AppState.service-count > 0 ? 1.0 : 0.0; } + MetricTile { label: "Discovered"; value: AppState.service-count; detail: "launchd, systemd, and Homebrew"; icon-glyph: "\u{f233}"; accent-color: Theme.palette.info; ratio: AppState.service-count > 0 ? 1.0 : 0.0; } MetricTile { label: "Running"; value: AppState.service-running-count; detail: "active services"; icon-glyph: "\u{f04b}"; accent-color: Theme.palette.success; ratio: AppState.service-count > 0 ? AppState.service-running-count / AppState.service-count : 0.0; } MetricTile { label: "Protected"; value: "System safe"; detail: "protected rows are locked"; icon-glyph: "\u{f023}"; accent-color: Theme.palette.warning; ratio: 1.0; } } @@ -1697,6 +1892,7 @@ export component AppWindow inherits Window { // the discovered/running totals on the metric tiles above. HorizontalLayout { spacing: Theme.spacing-md; + cross-axis-alignment: center; LineEdit { horizontal-stretch: 1; @@ -1773,7 +1969,7 @@ export component AppWindow inherits Window { // comfortable top padding so the first row breathes. // Hidden when empty so the empty/loading state reads // as one clean centered block, not a stray header. - if AppState.service-count > 0: HorizontalLayout { + if AppState.services.length > 0: HorizontalLayout { padding-left: Theme.spacing-md; padding-right: Theme.spacing-md; padding-top: Theme.spacing-sm; @@ -1786,28 +1982,40 @@ export component AppWindow inherits Window { Text { text: "ACTION"; color: Theme.palette.text-muted; font-size: Theme.font-size-xs; font-weight: 700; min-width: 168px; horizontal-alignment: right; } } - if AppState.service-count > 0: Rectangle { height: 1px; background: Theme.palette.border-subtle; } + if AppState.services.length > 0: Rectangle { height: 1px; background: Theme.palette.border-subtle; } - if AppState.service-count == 0: VerticalLayout { + if AppState.services.length == 0: VerticalLayout { min-height: 220px; vertical-stretch: 1; alignment: center; spacing: Theme.spacing-sm; - FaIcon { - glyph: AppState.loading-services ? "\u{f110}" : "\u{f233}"; + if AppState.loading-services: HorizontalLayout { + alignment: center; + Spinner { + running: true; + spinner-size: 26px; + spinner-color: Theme.palette.text-muted; + } + } + if !AppState.loading-services: FaIcon { + glyph: "\u{f233}"; icon-color: Theme.palette.text-muted; icon-size: Theme.font-size-xl; } Text { - text: AppState.loading-services ? "Discovering services..." : "No services loaded"; + text: AppState.loading-services + ? "Discovering services..." + : (AppState.service-count > 0 ? "No services match the current filter" : "No services loaded"); color: Theme.palette.text-secondary; font-size: Theme.font-size-md; font-weight: 600; horizontal-alignment: center; } Text { - text: AppState.loading-services ? "Querying launchd and Homebrew." : "Refresh to discover launchd and Homebrew services."; + text: AppState.loading-services + ? "Querying launchd, systemd, and Homebrew." + : (AppState.service-count > 0 ? "Clear the search filter or toggle switches above to view all services." : "Refresh to discover background services."); color: Theme.palette.text-muted; font-size: Theme.font-size-sm; horizontal-alignment: center; @@ -1867,7 +2075,8 @@ export component AppWindow inherits Window { spacing: Theme.spacing-lg; HorizontalLayout { - spacing: Theme.spacing-md; + spacing: Theme.spacing-sm; + cross-axis-alignment: center; VerticalLayout { horizontal-stretch: 1; @@ -1876,6 +2085,11 @@ export component AppWindow inherits Window { Text { text: AppState.apps-status; color: Theme.palette.text-secondary; font-size: Theme.font-size-sm; overflow: elide; } } + if AppState.apps-loading: Spinner { + running: true; + spinner-size: 16px; + } + ActionButton { label: AppState.apps-loading ? "Loading..." : "Refresh"; icon-glyph: "\u{f021}"; @@ -1884,6 +2098,10 @@ export component AppWindow inherits Window { } } + if AppState.apps-loading: ActivityBar { + running: true; + } + HorizontalLayout { spacing: Theme.spacing-md; vertical-stretch: 1; @@ -1922,7 +2140,37 @@ export component AppWindow inherits Window { } } - ScrollView { + if AppState.app-rows.length == 0: VerticalLayout { + min-height: 180px; + vertical-stretch: 1; + alignment: center; + spacing: Theme.spacing-sm; + + if AppState.apps-loading: HorizontalLayout { + alignment: center; + Spinner { + running: true; + spinner-size: 24px; + spinner-color: Theme.palette.text-muted; + } + } + if !AppState.apps-loading: FaIcon { + glyph: "\u{e196}"; + icon-color: Theme.palette.text-muted; + icon-size: Theme.font-size-xl; + } + Text { + text: AppState.apps-loading + ? "Scanning installed applications..." + : (AppState.app-filter != "" ? "No applications match the filter" : "No applications found"); + color: Theme.palette.text-secondary; + font-size: Theme.font-size-sm; + font-weight: 600; + horizontal-alignment: center; + } + } + + if AppState.app-rows.length > 0: ScrollView { vertical-stretch: 1; VerticalLayout { @@ -1951,20 +2199,17 @@ export component AppWindow inherits Window { HorizontalLayout { spacing: Theme.spacing-xs; alignment: start; + cross-axis-alignment: center; Text { text: a.name; color: Theme.palette.text-primary; font-size: Theme.font-size-sm; font-weight: 600; overflow: elide; vertical-alignment: center; } - // Both pills share the row's - // natural height, so they line - // up with the app name without - // any absolute positioning. if a.app-store: Badge { label: "App Store"; badge-color: Theme.palette.info; - min-width: 58px; + compact: true; } if a.brew: Badge { label: "brew"; badge-color: Theme.palette.warning; - min-width: 42px; + compact: true; } } Text { text: a.version == "" ? a.bundle-id : a.version + " · " + a.bundle-id; color: Theme.palette.text-muted; font-size: Theme.font-size-xs; overflow: elide; } @@ -2022,13 +2267,11 @@ export component AppWindow inherits Window { HorizontalLayout { spacing: Theme.spacing-sm; + cross-axis-alignment: center; Text { text: "Uninstall " + AppState.selected-app-name; color: Theme.palette.text-primary; font-size: Theme.font-size-lg; font-weight: 700; horizontal-stretch: 1; overflow: elide; } - if AppState.leftover-loading || AppState.uninstalling: HorizontalLayout { - alignment: center; - Spinner { - running: true; - spinner-size: 14px; - } + if AppState.leftover-loading || AppState.uninstalling: Spinner { + running: true; + spinner-size: 14px; } Text { text: AppState.uninstalling @@ -2274,6 +2517,14 @@ export component AppWindow inherits Window { spacing: Theme.spacing-md; alignment: center; + HorizontalLayout { + alignment: center; + Spinner { + running: true; + spinner-size: 28px; + } + } + Text { text: "Scanning... " + AppState.security-progress; color: Theme.palette.text-primary; @@ -2493,6 +2744,7 @@ export component AppWindow inherits Window { HorizontalLayout { spacing: Theme.spacing-sm; + cross-axis-alignment: center; // Change-kind chip: the same-version tamper is the bold red alarm. Badge { @@ -2848,6 +3100,39 @@ export component AppWindow inherits Window { } } } + + // ── Background helper ─────────────────────────────── + Panel { + VerticalLayout { + padding: Theme.spacing-lg; + spacing: Theme.spacing-md; + + SectionHeader { text: "BACKGROUND HELPER"; } + + SettingsField { + label: "Disk cleanup alert (%)"; + description: "Offer disk cleanup when disk usage reaches this percent. Set 0 to disable."; + SpinBox { + width: 130px; + minimum: 0; + maximum: 99; + value <=> AppState.setting-disk-pressure-percent; + } + } + Divider {} + + SettingsField { + label: "Memory cleanup alert (%)"; + description: "Offer memory cleanup when memory usage reaches this percent. Set 0 to disable."; + SpinBox { + width: 130px; + minimum: 0; + maximum: 99; + value <=> AppState.setting-memory-pressure-percent; + } + } + } + } } } diff --git a/crates/cleanton-app/ui/widgets.slint b/crates/cleanton-app/ui/widgets.slint index 52a1705..0cb88b9 100644 --- a/crates/cleanton-app/ui/widgets.slint +++ b/crates/cleanton-app/ui/widgets.slint @@ -132,18 +132,15 @@ export component Spinner inherits Rectangle { in property spinner-size: 14px; property phase: 0deg; - width: root.spinner-size; - height: root.spinner-size; + // Collapse to zero width when idle and never stretch horizontally inside a + // `HorizontalLayout`, while stretching vertically across row height so the + // rotating glyph centres itself via `y` without wrapper layouts. + min-width: root.running ? root.spinner-size : 0px; + max-width: root.running ? root.spinner-size : 0px; + min-height: root.spinner-size; + horizontal-stretch: 0; background: transparent; - // Invisible (and taking no visual space in the row) when not running: - // a static spinner glyph next to a title otherwise never goes away. visible: root.running; - // NOTE (Slint 1.17): a fixed-size element in a layout is placed at the - // START of the cross axis, so a bare Spinner sits at the top of a row (or - // hard left in a column) instead of centred. `cross-axis-self-alignment` - // would fix that for every caller at once, but it arrives in Slint 1.18, - // so each call site centres it instead: either a wrapper `HorizontalLayout - // { alignment: center; }` or a parent with `cross-axis-alignment: center`. Timer { interval: 90ms; @@ -175,7 +172,9 @@ export component ActivityBar inherits Rectangle { property phase: 0.0; visible: root.running; - height: 4px; + min-height: root.running ? 4px : 0px; + max-height: root.running ? 4px : 0px; + vertical-stretch: 0; background: Theme.palette.surface-elevated; border-radius: 2px; clip: true; @@ -416,13 +415,14 @@ export component SidebarItem inherits Rectangle { padding-left: Theme.spacing-sm; padding-right: Theme.spacing-sm; spacing: Theme.spacing-sm; + cross-axis-alignment: center; Rectangle { width: 3px; - height: 18px; + min-height: 18px; + max-height: 18px; border-radius: 2px; background: active ? Theme.palette.accent : transparent; - y: (parent.height - self.height) / 2; } FaIcon { @@ -851,45 +851,49 @@ export component SuggestionListRow inherits Rectangle { HorizontalLayout { alignment: end; spacing: Theme.spacing-xs; + cross-axis-alignment: center; Text { text: "Reveal"; color: Theme.palette.accent; font-size: Theme.font-size-xs; font-weight: 600; vertical-alignment: center; } - FaIcon { glyph: "\u{f35d}"; icon-color: Theme.palette.accent; icon-size: Theme.font-size-xs; width: 12px; y: (parent.height - self.height) / 2; } + FaIcon { glyph: "\u{f35d}"; icon-color: Theme.palette.accent; icon-size: Theme.font-size-xs; width: 12px; } } } // Two-step Trash affordance for deletable suggestions. Rectangle { width: 84px; - if root.deletable: HorizontalLayout { - alignment: end; - Rectangle { - width: 78px; - height: 28px; - y: (parent.height - self.height) / 2; - border-radius: Theme.radius-sm; - background: root.armed-delete - ? Theme.palette.danger - : (del-ta.has-hover ? Theme.palette.surface-elevated : transparent); - border-width: 1px; - border-color: root.armed-delete ? Theme.palette.danger : Theme.palette.border; - animate background, border-color { duration: Theme.anim-fast; } - - del-ta := TouchArea { - mouse-cursor: pointer; - clicked => { - if root.armed-delete { - root.confirm-delete(); - } else { - root.arm-delete(); + if root.deletable: VerticalLayout { + alignment: center; + HorizontalLayout { + alignment: end; + Rectangle { + width: 78px; + min-height: 28px; + max-height: 28px; + border-radius: Theme.radius-sm; + background: root.armed-delete + ? Theme.palette.danger + : (del-ta.has-hover ? Theme.palette.surface-elevated : transparent); + border-width: 1px; + border-color: root.armed-delete ? Theme.palette.danger : Theme.palette.border; + animate background, border-color { duration: Theme.anim-fast; } + + del-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + if root.armed-delete { + root.confirm-delete(); + } else { + root.arm-delete(); + } } } - } - Text { - text: root.armed-delete ? "Confirm" : "Trash"; - color: root.armed-delete ? #FFFFFF : Theme.palette.text-secondary; - font-size: Theme.font-size-xs; - font-weight: 650; - horizontal-alignment: center; - vertical-alignment: center; + Text { + text: root.armed-delete ? "Confirm" : "Trash"; + color: root.armed-delete ? #FFFFFF : Theme.palette.text-secondary; + font-size: Theme.font-size-xs; + font-weight: 650; + horizontal-alignment: center; + vertical-alignment: center; + } } } } @@ -1117,157 +1121,161 @@ export component ServiceListRow inherits Rectangle { Rectangle { width: 168px; - if root.protected-service: HorizontalLayout { - alignment: end; - spacing: Theme.spacing-xs; - FaIcon { - glyph: "\u{f023}"; - icon-color: Theme.palette.warning; - icon-size: Theme.font-size-xs; - width: 14px; - y: (parent.height - self.height) / 2; - } - Text { - text: "Protected"; - color: Theme.palette.text-muted; - font-size: Theme.font-size-xs; - vertical-alignment: center; - } - } + VerticalLayout { + alignment: center; - // Orphaned leftover: destructive Remove (two-step). - if !root.protected-service && root.orphaned: HorizontalLayout { - alignment: end; - Rectangle { - width: 92px; - height: 30px; - y: (parent.height - self.height) / 2; - border-radius: Theme.radius-sm; - background: root.armed-remove - ? Theme.palette.danger - : (rm-ta.has-hover ? Theme.palette.surface-elevated : transparent); - border-width: 1px; - border-color: root.armed-remove ? Theme.palette.danger : Theme.palette.border; - animate background, border-color { duration: Theme.anim-fast; } - - rm-ta := TouchArea { - mouse-cursor: pointer; - clicked => { - if root.armed-remove { - root.confirm-remove(root.id); - } else { - root.arm-remove(root.id); - } - } + if root.protected-service: HorizontalLayout { + alignment: end; + spacing: Theme.spacing-xs; + cross-axis-alignment: center; + FaIcon { + glyph: "\u{f023}"; + icon-color: Theme.palette.warning; + icon-size: Theme.font-size-xs; + width: 14px; } Text { - text: root.armed-remove ? "Confirm" : "Remove"; - color: root.armed-remove ? #FFFFFF : Theme.palette.text-secondary; + text: "Protected"; + color: Theme.palette.text-muted; font-size: Theme.font-size-xs; - font-weight: 650; - horizontal-alignment: center; vertical-alignment: center; } } - } - // Running service: two-step Disable. - if !root.protected-service && !root.orphaned && root.running: HorizontalLayout { - alignment: end; - - Rectangle { - width: 92px; - height: 30px; - y: (parent.height - self.height) / 2; - border-radius: Theme.radius-sm; - background: root.armed - ? Theme.palette.danger - : (btn-ta.has-hover ? Theme.palette.surface-elevated : transparent); - border-width: 1px; - border-color: root.armed ? Theme.palette.danger : Theme.palette.border; - - animate background, border-color { duration: Theme.anim-fast; } - - btn-ta := TouchArea { - mouse-cursor: pointer; - clicked => { - if root.armed { - root.confirm-disable(root.id); - } else { - root.arm-disable(root.id); + // Orphaned leftover: destructive Remove (two-step). + if !root.protected-service && root.orphaned: HorizontalLayout { + alignment: end; + Rectangle { + width: 92px; + min-height: 30px; + max-height: 30px; + border-radius: Theme.radius-sm; + background: root.armed-remove + ? Theme.palette.danger + : (rm-ta.has-hover ? Theme.palette.surface-elevated : transparent); + border-width: 1px; + border-color: root.armed-remove ? Theme.palette.danger : Theme.palette.border; + animate background, border-color { duration: Theme.anim-fast; } + + rm-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + if root.armed-remove { + root.confirm-remove(root.id); + } else { + root.arm-remove(root.id); + } } } - } - - Text { - text: root.armed ? "Confirm" : "Disable"; - color: root.armed ? #FFFFFF : Theme.palette.text-secondary; - font-size: Theme.font-size-xs; - font-weight: 650; - horizontal-alignment: center; - vertical-alignment: center; + Text { + text: root.armed-remove ? "Confirm" : "Remove"; + color: root.armed-remove ? #FFFFFF : Theme.palette.text-secondary; + font-size: Theme.font-size-xs; + font-weight: 650; + horizontal-alignment: center; + vertical-alignment: center; + } } } - } - // Stopped/disabled service: single-click Enable, plus a two-step - // Delete that removes the disabled startup item for good. - if !root.protected-service && !root.orphaned && !root.running: HorizontalLayout { - alignment: end; - spacing: Theme.spacing-xs; + // Running service: two-step Disable. + if !root.protected-service && !root.orphaned && root.running: HorizontalLayout { + alignment: end; + + Rectangle { + width: 92px; + min-height: 30px; + max-height: 30px; + border-radius: Theme.radius-sm; + background: root.armed + ? Theme.palette.danger + : (btn-ta.has-hover ? Theme.palette.surface-elevated : transparent); + border-width: 1px; + border-color: root.armed ? Theme.palette.danger : Theme.palette.border; + + animate background, border-color { duration: Theme.anim-fast; } + + btn-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + if root.armed { + root.confirm-disable(root.id); + } else { + root.arm-disable(root.id); + } + } + } - Rectangle { - width: 76px; - height: 30px; - y: (parent.height - self.height) / 2; - border-radius: Theme.radius-sm; - background: en-ta.has-hover ? Theme.palette.surface-elevated : transparent; - border-width: 1px; - border-color: Theme.palette.border; - - en-ta := TouchArea { - mouse-cursor: pointer; - clicked => { root.enable(root.id); } - } - Text { - text: "Enable"; - color: Theme.palette.accent; - font-size: Theme.font-size-xs; - font-weight: 650; - horizontal-alignment: center; - vertical-alignment: center; + Text { + text: root.armed ? "Confirm" : "Disable"; + color: root.armed ? #FFFFFF : Theme.palette.text-secondary; + font-size: Theme.font-size-xs; + font-weight: 650; + horizontal-alignment: center; + vertical-alignment: center; + } } } - Rectangle { - width: 76px; - height: 30px; - y: (parent.height - self.height) / 2; - border-radius: Theme.radius-sm; - background: root.armed-delete - ? Theme.palette.danger - : (del-ta.has-hover ? Theme.palette.surface-elevated : transparent); - border-width: 1px; - border-color: root.armed-delete ? Theme.palette.danger : Theme.palette.border; - animate background, border-color { duration: Theme.anim-fast; } - - del-ta := TouchArea { - mouse-cursor: pointer; - clicked => { - if root.armed-delete { - root.confirm-delete(root.id); - } else { - root.arm-delete(root.id); - } + // Stopped/disabled service: single-click Enable, plus a two-step + // Delete that removes the disabled startup item for good. + if !root.protected-service && !root.orphaned && !root.running: HorizontalLayout { + alignment: end; + spacing: Theme.spacing-xs; + + Rectangle { + width: 76px; + min-height: 30px; + max-height: 30px; + border-radius: Theme.radius-sm; + background: en-ta.has-hover ? Theme.palette.surface-elevated : transparent; + border-width: 1px; + border-color: Theme.palette.border; + + en-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.enable(root.id); } + } + Text { + text: "Enable"; + color: Theme.palette.accent; + font-size: Theme.font-size-xs; + font-weight: 650; + horizontal-alignment: center; + vertical-alignment: center; } } - Text { - text: root.armed-delete ? "Confirm" : "Delete"; - color: root.armed-delete ? #FFFFFF : Theme.palette.danger; - font-size: Theme.font-size-xs; - font-weight: 650; - horizontal-alignment: center; - vertical-alignment: center; + + Rectangle { + width: 76px; + min-height: 30px; + max-height: 30px; + border-radius: Theme.radius-sm; + background: root.armed-delete + ? Theme.palette.danger + : (del-ta.has-hover ? Theme.palette.surface-elevated : transparent); + border-width: 1px; + border-color: root.armed-delete ? Theme.palette.danger : Theme.palette.border; + animate background, border-color { duration: Theme.anim-fast; } + + del-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + if root.armed-delete { + root.confirm-delete(root.id); + } else { + root.arm-delete(root.id); + } + } + } + Text { + text: root.armed-delete ? "Confirm" : "Delete"; + color: root.armed-delete ? #FFFFFF : Theme.palette.danger; + font-size: Theme.font-size-xs; + font-weight: 650; + horizontal-alignment: center; + vertical-alignment: center; + } } } } diff --git a/crates/cleanton-cli/src/main.rs b/crates/cleanton-cli/src/main.rs index 139a40f..35bc081 100644 --- a/crates/cleanton-cli/src/main.rs +++ b/crates/cleanton-cli/src/main.rs @@ -296,7 +296,7 @@ async fn run_app(cli: Cli) -> Result { interval, once, print_agent, - } => Ok(run_watch(&cli, interval, once, print_agent)), + } => Ok(run_watch(&cli, &config, interval, once, print_agent)), Command::Interactive => run_interactive(&cli, &config).await, } } @@ -701,37 +701,62 @@ async fn run_clean(cli: &Cli, config: &CleantonConfig, dry_run: bool, yes: bool) /// BEFORE the exit code is computed, so `--review-all` on a surfaced set /// exits 0. /// The background watcher: a slow, cheap loop that checks packages the moment -/// they appear. -fn run_watch(cli: &Cli, interval: u64, once: bool, print_agent: bool) -> i32 { +/// they appear and offers disk or memory cleanup when system resources run high. +fn run_watch( + cli: &Cli, + config: &CleantonConfig, + interval: u64, + once: bool, + print_agent: bool, +) -> i32 { use cleanton_engine::background::{PackageWatcher, WatchEvent, WatchOptions}; - let options = WatchOptions::with_idle_seconds(interval); + let mut options = WatchOptions::with_idle_seconds(interval); + options.disk_pressure_percent = config.background.disk_pressure_percent; + options.memory_pressure_percent = config.background.memory_pressure_percent; let mut watcher = PackageWatcher::for_default_targets(options); if print_agent { - println!("{}", launch_agent_plist(interval)); + if cfg!(target_os = "linux") { + println!("{}", systemd_user_unit(interval)); + } else { + println!("{}", launch_agent_plist(interval)); + } return 0; } if !cli.json { let roots = watcher.roots().collect::>(); - if roots.is_empty() { + let monitors_resources = + options.disk_pressure_percent > 0 || options.memory_pressure_percent > 0; + if roots.is_empty() && !monitors_resources { println!( - "No package locations found to watch (no global node_modules, \ - site-packages, gem tree, Cargo registry, or Go module cache exists)." + "No package locations found to watch and resource pressure thresholds are disabled." ); return 0; } - println!( - "Watching {} package location(s); idle interval {}s, backing off to {}s while quiet.", - roots.len(), - options.idle.as_secs(), - options.max_idle.as_secs() - ); - for target in roots { - println!(" - {} ({})", target.root.display(), target.kind.label()); + if roots.is_empty() { + println!( + "Monitoring disk (>={}%) and memory (>={}%) pressure; idle interval {}s, backing off to {}s while quiet.", + options.disk_pressure_percent, + options.memory_pressure_percent, + options.idle.as_secs(), + options.max_idle.as_secs() + ); + } else { + println!( + "Watching {} package location(s) plus disk (>={}%) and memory (>={}%) usage; idle interval {}s, backing off to {}s while quiet.", + roots.len(), + options.disk_pressure_percent, + options.memory_pressure_percent, + options.idle.as_secs(), + options.max_idle.as_secs() + ); + for target in roots { + println!(" - {} ({})", target.root.display(), target.kind.label()); + } } - println!("Ctrl-C stops the watcher. Nothing is ever removed."); + println!("Ctrl-C stops the watcher. Nothing is ever removed without confirmation."); } let cancel = std::sync::atomic::AtomicBool::new(false); @@ -767,7 +792,7 @@ fn run_watch(cli: &Cli, interval: u64, once: bool, print_agent: bool) -> i32 { /// One watch event as JSON (the `--json` stream shape). fn watch_event_json(event: &cleanton_engine::background::WatchEvent) -> serde_json::Value { - use cleanton_engine::background::WatchEvent; + use cleanton_engine::background::{CleanupOffer, WatchEvent}; match event { WatchEvent::Quiet => serde_json::json!({ "event": "quiet" }), @@ -802,13 +827,44 @@ fn watch_event_json(event: &cleanton_engine::background::WatchEvent) -> serde_js used_percent, available_bytes, suggestion, - } => serde_json::json!({ - "event": "resource_pressure", - "kind": kind.label(), - "used_percent": used_percent, - "available_bytes": available_bytes, - "suggestion": suggestion, - }), + offer, + } => { + let offer_json = match offer { + CleanupOffer::Disk { + candidates, + estimated_reclaimable_bytes, + } => serde_json::json!({ + "type": "disk_cleanup", + "estimated_reclaimable_bytes": estimated_reclaimable_bytes, + "recommended_command": "cleanton clean ~", + "candidates": candidates.iter().map(|c| serde_json::json!({ + "label": c.label, + "path": c.path.display().to_string(), + "bytes": c.bytes, + })).collect::>(), + }), + CleanupOffer::Memory { + top_processes, + recommended_command, + } => serde_json::json!({ + "type": "memory_cleanup", + "recommended_command": recommended_command, + "top_processes": top_processes.iter().map(|p| serde_json::json!({ + "pid": p.pid, + "name": p.name, + "rss_bytes": p.rss_bytes, + })).collect::>(), + }), + }; + serde_json::json!({ + "event": "resource_pressure", + "kind": kind.label(), + "used_percent": used_percent, + "available_bytes": available_bytes, + "suggestion": suggestion, + "offer": offer_json, + }) + } WatchEvent::ContentFinding { package, verdict, @@ -825,7 +881,7 @@ fn watch_event_json(event: &cleanton_engine::background::WatchEvent) -> serde_js } fn print_watch_event(event: &cleanton_engine::background::WatchEvent) { - use cleanton_engine::background::WatchEvent; + use cleanton_engine::background::{CleanupOffer, WatchEvent}; use cleanton_engine::security::{severity_label, verdict_label}; match event { @@ -848,11 +904,29 @@ fn print_watch_event(event: &cleanton_engine::background::WatchEvent) { kind, used_percent, suggestion, + offer, .. - } => println!( - "[pressure] {} at {used_percent}% used - {suggestion}", - kind.label() - ), + } => { + println!( + "[pressure] {} at {used_percent}% used - {suggestion}", + kind.label() + ); + match offer { + CleanupOffer::Disk { .. } => { + println!( + " -> Disk cleanup offer: run `cleanton plan ~` to preview or `cleanton clean ~` to move safe caches and build artifacts to Trash." + ); + } + CleanupOffer::Memory { + recommended_command, + .. + } => { + println!( + " -> Memory cleanup offer: run `{recommended_command}` to inspect and stop idle background services." + ); + } + } + } WatchEvent::ContentFinding { package, verdict, @@ -865,6 +939,18 @@ fn print_watch_event(event: &cleanton_engine::background::WatchEvent) { } } +/// A systemd user unit that runs the watcher on Linux (Ubuntu). Printed, not +/// installed: writing to `~/.config/systemd/user` stays the user's decision. +fn systemd_user_unit(interval: u64) -> String { + let binary = std::env::current_exe().map_or_else( + |_| "/usr/local/bin/cleanton".to_owned(), + |path| path.display().to_string(), + ); + format!( + "[Unit]\nDescription=Cleanton background package and resource helper\n\n[Service]\nType=simple\nExecStart={binary} watch --interval {interval}\nNice=10\nIOSchedulingClass=idle\nRestart=on-failure\n\n[Install]\nWantedBy=default.target" + ) +} + /// A launchd agent that runs the watcher as a user background job. Printed, not /// installed: the plist path and the decision to load it are the user's. fn launch_agent_plist(interval: u64) -> String { diff --git a/crates/cleanton-common/src/config.rs b/crates/cleanton-common/src/config.rs index 271bf14..4bbbe91 100644 --- a/crates/cleanton-common/src/config.rs +++ b/crates/cleanton-common/src/config.rs @@ -15,6 +15,7 @@ pub struct CleantonConfig { pub safety: SafetyConfig, pub ml: MlConfig, pub ui: UiConfig, + pub background: BackgroundConfig, } impl CleantonConfig { @@ -125,13 +126,11 @@ pub struct ScanningConfig { impl Default for ScanningConfig { fn default() -> Self { Self { - // Depth 10 from the home root reaches essentially every cleanable - // cache / build directory (`node_modules`, `target`, `~/Library/ - // Caches/*`, `.venv`, ...) while keeping a full-home scan from - // exploding into millions of entries held in memory. The whole - // cleanable directory is matched and size-aggregated from what we - // walk; users who want exhaustive depth can raise this. - max_depth: 10, + // Depth 20 matches ScanOptions::default() and reaches nested build + // outputs (Xcode DerivedData object dirs, Python .venv site-packages, + // Maven/Gradle repository hierarchies, monorepo workspaces) without + // silently truncating directory sizes at depth 10. + max_depth: 20, follow_symlinks: false, respect_gitignore: true, parallel_threads: 0, @@ -213,6 +212,30 @@ impl Default for UiConfig { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct BackgroundConfig { + /// Monitor disk and memory pressure in the background while Cleanton is running. + pub enabled: bool, + /// Home-volume used percentage at or above which Cleanton offers disk cleanup (`0` disables). + pub disk_pressure_percent: u8, + /// System memory used percentage at or above which Cleanton offers memory cleanup (`0` disables). + pub memory_pressure_percent: u8, + /// Poll interval in seconds for background resource pressure checks. + pub poll_interval_secs: u64, +} + +impl Default for BackgroundConfig { + fn default() -> Self { + Self { + enabled: true, + disk_pressure_percent: 85, + memory_pressure_percent: 85, + poll_interval_secs: 30, + } + } +} + #[cfg(test)] mod tests { use std::fs; diff --git a/crates/cleanton-common/src/lib.rs b/crates/cleanton-common/src/lib.rs index d24c265..1a5a88f 100644 --- a/crates/cleanton-common/src/lib.rs +++ b/crates/cleanton-common/src/lib.rs @@ -6,7 +6,10 @@ pub mod safety; pub mod types; pub mod usage_history; -pub use config::{CleantonConfig, GeneralConfig, MlConfig, SafetyConfig, ScanningConfig, UiConfig}; +pub use config::{ + BackgroundConfig, CleantonConfig, GeneralConfig, MlConfig, SafetyConfig, ScanningConfig, + UiConfig, +}; pub use error::{CleantonError, Result}; pub use paths::{FIRMLINK_ALIASES, firmlink_canonical}; pub use safety::{ diff --git a/crates/cleanton-common/src/types.rs b/crates/cleanton-common/src/types.rs index 3a37fd2..1525e3d 100644 --- a/crates/cleanton-common/src/types.rs +++ b/crates/cleanton-common/src/types.rs @@ -104,6 +104,15 @@ pub enum Ecosystem { Node, Python, Go, + Jvm, + CCpp, + Dotnet, + Dart, + Elixir, + Haskell, + Zig, + Php, + Ruby, Docker, Xcode, Git, diff --git a/crates/cleanton-engine/src/analyzer.rs b/crates/cleanton-engine/src/analyzer.rs index 761d2a7..c15ac1d 100644 --- a/crates/cleanton-engine/src/analyzer.rs +++ b/crates/cleanton-engine/src/analyzer.rs @@ -10,10 +10,7 @@ use std::time::SystemTime; use cleanton_common::{CleantonConfig, CleanupAction, CleanupPlan, FileKind, ScanResult}; use cleanton_ml::MlEngine; -use cleanton_recipes::{ - DirectoryGate, RecipeDocument, RecipeSet, action_for_entry_with_context, - directory_gate_with_context, recipe_has_gates, -}; +use cleanton_recipes::{CompiledRecipeMatcher, RecipeDocument, RecipeSet}; use crate::ml::{ ActionAnnotation, AnnotatedAction, DEFAULT_ML_CONFIDENCE_THRESHOLD, FileClassifier, @@ -142,73 +139,74 @@ fn match_recipes(scan: &ScanResult, recipes: &RecipeSet) -> Vec { .filter(|recipe| recipe.recipe.enabled) .collect::>(); ordered.sort_by_key(|recipe| Reverse(recipe.recipe.priority)); + let compiled = CompiledRecipeMatcher::new(&ordered); // Precompute, from the scan we already performed, the newest modification - // time within each scanned directory's subtree. `min_age_days` directory - // recipes need this; without it the matcher re-walks every matched - // directory's subtree from disk (an O(n²) `stat` storm on trees full of - // `node_modules` / `target`). One O(entries) pass over in-memory data - // replaces all of it. + // time within each scanned directory's subtree. let newest = newest_subtree_mtimes(scan); - // Memoized "does this directory contain ?" probe. Without it the - // matcher runs one `stat(2)` per (entry, recipe) pair for every - // `parent_must_contain` / `self_must_contain` recipe — tens of millions of - // syscalls on a home-sized scan, and the single biggest cost of the - // post-scan analysis. + // Memoized "does this directory contain ?" probe. let markers = MarkerCache::new(ordered.iter().copied()); - // Per recipe, the directory roots it must not reach inside of (see - // `suppressed_roots`). - let suppressed = suppressed_roots(scan, &ordered, &newest, &markers); + // Per recipe, the directory roots it must not reach inside of (built in a + // single pass over scanned directories using the indexed matcher). + let suppressed = suppressed_roots(scan, &compiled, &newest, &markers); - // Matching stays single-threaded on purpose: the per-match work is dominated - // by a process-wide compiled-glob cache, and fanning this loop across the CPU - // pool turned that cache's lock into a contention hot spot that ran *slower* - // than serial. First matching recipe wins per entry. - scan.entries + let mut candidates_buf = Vec::with_capacity(16); + let mut matched: Vec<(PlannedAction, bool)> = scan + .entries .iter() .filter_map(|entry| { - ordered - .iter() - .copied() - .enumerate() - .find_map(|(index, recipe)| { - // Suppression is per recipe, not global: another recipe with - // its own intent (say a stray model file) may still legitimately - // match inside this directory, so we fall through rather than - // skipping the entry outright. - if suppressed - .get(index) - .is_some_and(|roots| is_under_suppressed_root(&entry.path, roots)) - { - return None; - } - action_for_entry_with_context( - recipe, - entry, - |dir, marker| markers.contains(dir, marker), - |dir| newest.get(dir).copied(), - ) - }) + compiled + .first_matching_action( + entry, + &mut candidates_buf, + |index| { + suppressed + .get(index) + .is_some_and(|roots| is_under_suppressed_root(&entry.path, roots)) + }, + |dir, marker| markers.contains(dir, marker), + |dir| newest.get(dir).copied(), + ) + .map(|action| (action, entry.is_dir && entry.size == 0)) }) - .collect() + .collect(); + + // When a matched directory sat right at the walker's `max_depth` ceiling, + // its children were not walked so `entry.size` is 0. Backfill those via + // targeted sizing so reclaimable-byte totals remain accurate. + let unmeasured: Vec = matched + .iter() + .filter(|(_, needs_size)| *needs_size) + .filter(|(action, _)| action.target.is_dir()) + .map(|(action, _)| action.target.clone()) + .collect(); + if !unmeasured.is_empty() { + let sized: HashMap = cleanton_scanner::size_targets(&unmeasured) + .into_iter() + .collect(); + for (action, needs_size) in &mut matched { + if *needs_size + && let Some(&bytes) = sized.get(&action.target) + && bytes > 0 + { + action.size = bytes; + } + } + } + + matched.into_iter().map(|(action, _)| action).collect() } /// Marker bitmaps per directory, memoized for one analysis pass. /// -/// The matcher's context proofs (`parent_must_contain`, `parent_must_contain_any`, -/// `self_must_contain`) ask "does this directory contain ``?" once per -/// (entry, recipe) pair, which on a home-sized scan means millions of `stat(2)` -/// calls for the same few dozen (directory, marker) combinations. Answers are -/// memoized per directory in two bitmaps — probed markers and existing markers — -/// so each pair costs at most one syscall, once. `overflow` (more than 64 -/// distinct markers, e.g. after a user adds many custom recipes) falls back to -/// probing directly, which is always correct. +/// Uses `u128` bitmaps so up to 128 distinct context markers across built-in and +/// user recipes stay completely heap-free per directory. #[derive(Debug, Default, Clone, Copy)] struct DirProbe { - probed: u64, - exists: u64, + probed: u128, + exists: u128, } #[derive(Debug)] @@ -241,7 +239,7 @@ impl<'a> MarkerCache<'a> { if markers.contains_key(marker.as_str()) { continue; } - if markers.len() >= u64::BITS as usize { + if markers.len() >= u128::BITS as usize { overflow = true; continue; } @@ -269,9 +267,7 @@ impl<'a> MarkerCache<'a> { return dir.join(marker).exists(); } - let mask = 1_u64 << bit; - // Scope the shared borrow: an `if`-condition temporary would otherwise - // stay alive into the block and panic on the `borrow_mut` below. + let mask = 1_u128 << bit; let is_last = { let last = self.last_dir.borrow(); last.as_deref() == Some(dir) @@ -297,42 +293,33 @@ impl<'a> MarkerCache<'a> { } } -/// For each recipe (parallel to `ordered`), the directories whose gate it failed. -/// -/// A recipe whose glob selects a directory *and* everything beneath it means -/// "this directory, as a unit". When its size/age gate rejects the directory, -/// the recipe must not then act on the directory's contents individually: that -/// inverts the gate's intent (shredding an actively-used cache the gate was -/// protecting) and replaces one folder action with thousands of per-file ones. -/// Only gated recipes can fail a gate, so the rest are skipped without touching -/// a single directory. +/// For each recipe (parallel to `compiled`), the directories whose gate it failed. fn suppressed_roots<'a>( scan: &'a ScanResult, - ordered: &[&RecipeDocument], + compiled: &CompiledRecipeMatcher<'_>, newest: &HashMap<&Path, SystemTime>, markers: &MarkerCache<'_>, ) -> Vec> { - ordered + let mut suppressed = vec![HashSet::new(); compiled.len()]; + let mut candidates_buf = Vec::with_capacity(16); + for entry in scan + .entries .iter() - .map(|recipe| { - if !recipe_has_gates(recipe) { - return HashSet::new(); + .filter(|entry| entry.kind == FileKind::Directory) + { + let failed = compiled.gate_failed_recipe_indices( + entry, + &mut candidates_buf, + |dir, marker| markers.contains(dir, marker), + |dir| newest.get(dir).copied(), + ); + for recipe_idx in failed { + if let Some(roots) = suppressed.get_mut(recipe_idx) { + roots.insert(entry.path.as_path()); } - scan.entries - .iter() - .filter(|entry| entry.kind == FileKind::Directory) - .filter(|entry| { - directory_gate_with_context( - recipe, - entry, - |dir, marker| markers.contains(dir, marker), - |dir| newest.get(dir).copied(), - ) == DirectoryGate::GateFailed - }) - .map(|entry| entry.path.as_path()) - .collect() - }) - .collect() + } + } + suppressed } /// True when `path` is a suppressed root or lives beneath one. The root itself is diff --git a/crates/cleanton-engine/src/background.rs b/crates/cleanton-engine/src/background.rs index edf3f95..ae0345d 100644 --- a/crates/cleanton-engine/src/background.rs +++ b/crates/cleanton-engine/src/background.rs @@ -68,8 +68,8 @@ impl Default for WatchOptions { max_idle: Duration::from_mins(15), burst: Duration::from_secs(5), quiet_ticks_before_backoff: 4, - disk_pressure_percent: 90, - memory_pressure_percent: 90, + disk_pressure_percent: 85, + memory_pressure_percent: 85, } } } @@ -112,15 +112,17 @@ pub enum WatchEvent { /// A newly installed package runs a script at install time. Not a verdict, /// just the fact that makes install-time compromise possible. InstallHook { package: String, hook: String }, - /// Disk or memory usage crossed its pressure threshold. Edge-triggered: - /// emitted once when pressure starts, and again only after usage has - /// dropped back below the threshold. `suggestion` is the user-facing - /// cleanup advice (the watcher itself never deletes or purges anything). + /// Disk or memory usage crossed its pressure threshold. Edge-triggered with + /// hysteresis: emitted once when pressure starts, and again only after usage + /// has dropped comfortably back below the threshold. `suggestion` is the + /// user-facing cleanup advice and `offer` carries concrete candidates so + /// CLI/TUI/GUI surfaces can offer one-step reviewed cleanup. ResourcePressure { kind: ResourceKind, used_percent: u8, available_bytes: u64, suggestion: String, + offer: CleanupOffer, }, /// A newly installed package did not come back clean from the content scan. ContentFinding { @@ -131,6 +133,48 @@ pub enum WatchEvent { }, } +/// One measured reclaimable directory surfaced in a disk-pressure cleanup offer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiskReliefCandidate { + pub label: String, + pub path: PathBuf, + pub bytes: u64, +} + +/// One high-RSS user process surfaced in a memory-pressure cleanup offer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MemoryProcessCandidate { + pub pid: u32, + pub name: String, + pub rss_bytes: u64, +} + +/// Structured cleanup offer emitted when disk or memory usage is too high. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CleanupOffer { + Disk { + candidates: Vec, + estimated_reclaimable_bytes: u64, + }, + Memory { + top_processes: Vec, + recommended_command: String, + }, +} + +/// Live resource snapshot used by the desktop and terminal UIs' background helper. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ResourceStatus { + pub disk_used_percent: u8, + pub disk_available_bytes: u64, + pub disk_total_bytes: u64, + pub memory_used_percent: u8, + pub memory_available_bytes: u64, + pub memory_total_bytes: u64, + pub disk_offer: Option<(String, CleanupOffer)>, + pub memory_offer: Option<(String, CleanupOffer)>, +} + impl WatchEvent { /// Whether this event is worth waking a user for. #[must_use] @@ -165,11 +209,17 @@ struct RootState { entries: BTreeMap>, } +/// Hysteresis band (in percentage points) below the pressure threshold before a +/// previously triggered alert rearms, preventing repeated notifications when +/// usage hovers near the threshold. +const PRESSURE_HYSTERESIS_PERCENT: u8 = 5; + /// The watcher itself: targets plus their last snapshot. #[derive(Debug)] pub struct PackageWatcher { roots: Vec<(SecurityTarget, RootState)>, advisories: AdvisoryDatabase, + scanner: Scanner, options: WatchOptions, disk_pressure_active: bool, memory_pressure_active: bool, @@ -191,6 +241,7 @@ impl PackageWatcher { .map(|target| (target, RootState::default())) .collect(), advisories: AdvisoryDatabase::load(), + scanner: Scanner::with_default_engines(ScanConfig::default()), options, disk_pressure_active: false, memory_pressure_active: false, @@ -256,7 +307,12 @@ impl PackageWatcher { let examined = candidates.len(); for name in &candidates { for dir in expand_entry(&target.root, name) { - events.extend(check_package(&self.advisories, target.kind, &dir)); + events.extend(check_package( + &self.advisories, + &self.scanner, + target.kind, + &dir, + )); } } events.push(WatchEvent::RootChanged { @@ -271,9 +327,9 @@ impl PackageWatcher { events } - /// Edge-triggered disk/memory pressure check: two syscalls-scale reads - /// (`statfs` on the home volume, `sysconf` for memory), so it is cheap - /// enough to run every tick alongside the package-root stats. + /// Edge-triggered disk/memory pressure check with hysteresis: two syscall-scale + /// reads (`statfs` on the home volume, `/proc/meminfo` or `host_statistics64` + /// for memory), cheap enough to run every tick alongside package-root stats. fn resource_pressure_events(&mut self) -> Vec { let mut events = Vec::new(); if self.options.disk_pressure_percent > 0 @@ -289,16 +345,21 @@ impl PackageWatcher { let used_percent = ((capacity.used_bytes as f64 / capacity.total_bytes as f64) * 100.0) .round() .clamp(0.0, 100.0) as u8; - let active = used_percent >= self.options.disk_pressure_percent; - if active && !self.disk_pressure_active { + let threshold = self.options.disk_pressure_percent; + let clear_below = threshold.saturating_sub(PRESSURE_HYSTERESIS_PERCENT); + if used_percent >= threshold && !self.disk_pressure_active { + self.disk_pressure_active = true; + let (suggestion, offer) = build_disk_offer(used_percent, &capacity, &home); events.push(WatchEvent::ResourcePressure { kind: ResourceKind::Disk, used_percent, available_bytes: capacity.available_bytes, - suggestion: disk_advice(used_percent, &capacity, &home), + suggestion, + offer, }); + } else if used_percent <= clear_below { + self.disk_pressure_active = false; } - self.disk_pressure_active = active; } if self.options.memory_pressure_percent > 0 && let Some(memory) = cleanton_scanner::memory_usage() @@ -309,16 +370,22 @@ impl PackageWatcher { clippy::cast_sign_loss )] let used_percent = (memory.used_fraction * 100.0).round().clamp(0.0, 100.0) as u8; - let active = used_percent >= self.options.memory_pressure_percent; - if active && !self.memory_pressure_active { + let threshold = self.options.memory_pressure_percent; + let clear_below = threshold.saturating_sub(PRESSURE_HYSTERESIS_PERCENT); + if used_percent >= threshold && !self.memory_pressure_active { + self.memory_pressure_active = true; + let (suggestion, offer) = + build_memory_offer(used_percent, memory.available_bytes); events.push(WatchEvent::ResourcePressure { kind: ResourceKind::Memory, used_percent, available_bytes: memory.available_bytes, - suggestion: memory_advice(used_percent, memory.available_bytes), + suggestion, + offer, }); + } else if used_percent <= clear_below { + self.memory_pressure_active = false; } - self.memory_pressure_active = active; } events } @@ -360,7 +427,51 @@ const RELIEF_ENTRY_CAP: usize = 20_000; const RELIEF_MIN_BYTES: u64 = 64 * 1024 * 1024; /// How many reclaimable directories the advice names. -const RELIEF_TOP_N: usize = 3; +const RELIEF_TOP_N: usize = 4; + +/// How many high-memory processes are named in memory relief offers. +const MEMORY_TOP_PROCESSES: usize = 4; + +/// Capture a live snapshot of disk and memory usage, computing cleanup offers +/// only when a configured threshold is exceeded. +#[must_use] +pub fn snapshot_resource_status(disk_threshold: u8, memory_threshold: u8) -> ResourceStatus { + let mut status = ResourceStatus::default(); + if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) + && let Ok(capacity) = cleanton_scanner::volume_capacity(&home) + && capacity.total_bytes > 0 + { + #[allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss + )] + let used_percent = ((capacity.used_bytes as f64 / capacity.total_bytes as f64) * 100.0) + .round() + .clamp(0.0, 100.0) as u8; + status.disk_used_percent = used_percent; + status.disk_available_bytes = capacity.available_bytes; + status.disk_total_bytes = capacity.total_bytes; + if disk_threshold > 0 && used_percent >= disk_threshold { + status.disk_offer = Some(build_disk_offer(used_percent, &capacity, &home)); + } + } + if let Some(memory) = cleanton_scanner::memory_usage() { + #[allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss + )] + let used_percent = (memory.used_fraction * 100.0).round().clamp(0.0, 100.0) as u8; + status.memory_used_percent = used_percent; + status.memory_available_bytes = memory.available_bytes; + status.memory_total_bytes = memory.total_bytes; + if memory_threshold > 0 && used_percent >= memory_threshold { + status.memory_offer = Some(build_memory_offer(used_percent, memory.available_bytes)); + } + } + status +} /// Sum a directory against a budget shared by the whole measurement, stopping /// as soon as it is spent. The figure is therefore a lower bound, which is the @@ -385,14 +496,11 @@ fn sized_dir(path: &Path, budget: &mut usize) -> u64 { total } -/// The largest reclaimable directories under the two user cache conventions, -/// plus the Trash, sorted largest-first and capped at [`RELIEF_TOP_N`]. -/// -/// `~/.Trash` is measured as a single entry rather than per file: emptying it is -/// one action, and that is precisely why it is worth naming. -fn reclaimable_top(home: &Path) -> Vec<(String, u64)> { +/// The largest reclaimable directories under macOS and Linux cache/developer/Trash +/// locations, sorted largest-first and capped at [`RELIEF_TOP_N`]. +fn measure_disk_relief_candidates(home: &Path) -> Vec { let mut budget = RELIEF_ENTRY_CAP; - let mut found: Vec<(String, u64)> = Vec::new(); + let mut found: Vec = Vec::new(); for root in [home.join("Library/Caches"), home.join(".cache")] { let Ok(entries) = std::fs::read_dir(&root) else { continue; @@ -408,18 +516,42 @@ fn reclaimable_top(home: &Path) -> Vec<(String, u64)> { let name = entry.file_name().to_string_lossy().into_owned(); let size = sized_dir(&path, &mut budget); if size >= RELIEF_MIN_BYTES { - found.push((name, size)); + found.push(DiskReliefCandidate { + label: name, + path, + bytes: size, + }); } } } - let trash = home.join(".Trash"); - if trash.is_dir() { - let size = sized_dir(&trash, &mut budget); - if size >= RELIEF_MIN_BYTES { - found.push(("Trash".to_owned(), size)); + + for (label, path) in [ + ( + "Xcode DerivedData", + home.join("Library/Developer/Xcode/DerivedData"), + ), + ("Cargo registry", home.join(".cargo/registry")), + ("Gradle caches", home.join(".gradle/caches")), + ("npm cache", home.join(".npm/_cacache")), + ("Trash", home.join(".Trash")), + ("Trash", home.join(".local/share/Trash/files")), + ] { + if budget == 0 { + break; + } + if path.is_dir() { + let size = sized_dir(&path, &mut budget); + if size >= RELIEF_MIN_BYTES { + found.push(DiskReliefCandidate { + label: label.to_owned(), + path, + bytes: size, + }); + } } } - found.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + + found.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.label.cmp(&b.label))); found.truncate(RELIEF_TOP_N); found } @@ -443,40 +575,89 @@ fn human_bytes(bytes: u64) -> String { } } -/// Advice for a disk-pressure event, naming the biggest reclaimable directories -/// when they can be measured. Falls back to plain guidance when nothing -/// reclaimable is found (or the walk is not affordable right now). -fn disk_advice(used_percent: u8, capacity: &VolumeCapacity, home: &Path) -> String { +fn build_disk_offer( + used_percent: u8, + capacity: &VolumeCapacity, + home: &Path, +) -> (String, CleanupOffer) { let free = human_bytes(capacity.available_bytes); - let mut advice = format!("Disk is {used_percent}% full ({free} available)."); - let top = reclaimable_top(home); - if top.is_empty() { + let candidates = measure_disk_relief_candidates(home); + let estimated_reclaimable_bytes: u64 = candidates + .iter() + .fold(0_u64, |acc, item| acc.saturating_add(item.bytes)); + + let mut advice = format!("Disk usage is high at {used_percent}% ({free} available)."); + if candidates.is_empty() { advice.push_str( - " Run a Cleanton scan to find caches and large files, then empty the Trash: \ - items you have already trashed keep occupying space until it is emptied.", + " Offer disk cleanup: run a Cleanton scan to review caches and build artifacts, \ + then empty the Trash to reclaim freed space.", ); - return advice; + } else { + let named: Vec = candidates + .iter() + .map(|c| format!("{} ({})", c.label, human_bytes(c.bytes))) + .collect(); + advice.push_str(&format!( + " At least {} is reclaimable across {}. Run Disk Cleanup to review and move caches to Trash, or empty the Trash.", + human_bytes(estimated_reclaimable_bytes), + named.join(", ") + )); } - let named: Vec = top - .iter() - .map(|(name, size)| format!("{name} ({})", human_bytes(*size))) - .collect(); - advice.push_str(" Biggest reclaimable right now: "); - advice.push_str(&named.join(", ")); - advice.push('.'); - advice.push_str( - " Run a Cleanton scan to review them, then empty the Trash to actually reclaim the space.", - ); - advice + + ( + advice, + CleanupOffer::Disk { + candidates, + estimated_reclaimable_bytes, + }, + ) } -/// Advice for a memory-pressure event. Cleanton reports; it never kills a -/// process or purges memory, and the advice says so. -fn memory_advice(used_percent: u8, available_bytes: u64) -> String { - format!( - "Memory is {used_percent}% used ({} available). Quit or restart the heaviest app; \ - Cleanton never kills processes or purges memory for you.", +fn build_memory_offer(used_percent: u8, available_bytes: u64) -> (String, CleanupOffer) { + // Immediately return any unused Cleanton allocator pages back to the OS. + let _ = cleanton_scanner::reclaim_process_memory(); + + let top_processes: Vec = + cleanton_scanner::top_memory_processes(MEMORY_TOP_PROCESSES) + .into_iter() + .map(|sample| MemoryProcessCandidate { + pid: sample.pid, + name: sample.name, + rss_bytes: sample.rss_bytes, + }) + .collect(); + + let mut advice = format!( + "Memory usage is high at {used_percent}% ({} available).", human_bytes(available_bytes) + ); + if !top_processes.is_empty() { + let summary: Vec = top_processes + .iter() + .map(|proc_entry| { + format!( + "{} ({}, PID {})", + proc_entry.name, + human_bytes(proc_entry.rss_bytes), + proc_entry.pid + ) + }) + .collect(); + advice.push_str(" Heaviest user processes: "); + advice.push_str(&summary.join(", ")); + advice.push('.'); + } + advice.push_str( + " Offer memory cleanup: stop idle background services in Cleanton Services (`cleanton services list`) \ + or close unused heavy apps; Cleanton reclaimed its own unused allocator pages and never kills user apps without review.", + ); + + ( + advice, + CleanupOffer::Memory { + top_processes, + recommended_command: "cleanton services list".to_owned(), + }, ) } @@ -484,6 +665,7 @@ fn memory_advice(used_percent: u8, available_bytes: u64) -> String { /// a single-directory content scan. fn check_package( advisories: &AdvisoryDatabase, + scanner: &Scanner, kind: SecurityTargetKind, dir: &Path, ) -> Vec { @@ -515,7 +697,6 @@ fn check_package( // `SecurityTargetKind::is_version_only`. return events; } - let scanner = Scanner::with_default_engines(ScanConfig::default()); if let Ok(report) = scanner.scan_path(dir) && !matches!(report.verdict, Verdict::Clean) { @@ -792,6 +973,35 @@ mod tests { assert!(options.burst <= options.idle); } + #[test] + fn disk_and_memory_cleanup_offers_are_populated() { + let temp = tempfile::tempdir().unwrap(); + let big_cache = temp.path().join(".cache/pip"); + std::fs::create_dir_all(&big_cache).unwrap(); + std::fs::write(big_cache.join("wheel.bin"), vec![0_u8; 70 * 1024 * 1024]).unwrap(); + + let cap = VolumeCapacity { + total_bytes: 100 * 1024 * 1024 * 1024, + available_bytes: 10 * 1024 * 1024 * 1024, + used_bytes: 90 * 1024 * 1024 * 1024, + }; + let (advice, offer) = build_disk_offer(90, &cap, temp.path()); + assert!(advice.contains("pip"), "expected cache candidate in {advice}"); + assert!(matches!(offer, CleanupOffer::Disk { .. })); + if let CleanupOffer::Disk { + candidates, + estimated_reclaimable_bytes, + } = offer + { + assert!(!candidates.is_empty()); + assert!(estimated_reclaimable_bytes >= 64 * 1024 * 1024); + } + + let (mem_advice, mem_offer) = build_memory_offer(92, 512 * 1024 * 1024); + assert!(mem_advice.contains("Memory usage is high")); + assert!(matches!(mem_offer, CleanupOffer::Memory { .. })); + } + #[test] fn identity_is_read_from_the_manifest_not_the_directory_name() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/cleanton-engine/src/lib.rs b/crates/cleanton-engine/src/lib.rs index 07e74c3..dc84979 100644 --- a/crates/cleanton-engine/src/lib.rs +++ b/crates/cleanton-engine/src/lib.rs @@ -20,7 +20,10 @@ pub use advisories::{ compare_versions, }; pub use analyzer::AnalyzedPlan; -pub use background::{PackageWatcher, ResourceKind, WatchEvent, WatchOptions}; +pub use background::{ + CleanupOffer, DiskReliefCandidate, MemoryProcessCandidate, PackageWatcher, ResourceKind, + ResourceStatus, WatchEvent, WatchOptions, snapshot_resource_status, +}; pub use docker::{ BuildCacheInfo, ContainerInfo, DockerAnalysis, DockerCleaner, DockerCleanupPlan, DockerCleanupResult, ImageInfo, VolumeInfo, diff --git a/crates/cleanton-engine/src/ml.rs b/crates/cleanton-engine/src/ml.rs index 86cdfb4..7c5b159 100644 --- a/crates/cleanton-engine/src/ml.rs +++ b/crates/cleanton-engine/src/ml.rs @@ -155,34 +155,47 @@ pub(crate) fn adjust_actions( mut actions: Vec, scan: &ScanResult, ) -> Adjusted { - let entries: HashMap<&Path, &FileEntry> = scan - .entries - .iter() - .map(|entry| (entry.path.as_path(), entry)) - .collect(); - let contexts = build_contexts(scan); + if actions.is_empty() { + return Adjusted::default(); + } - // When the candidate set exceeds the evaluation cap, evaluate the largest - // candidates (the ones that drive the reclaimable total and the executable - // plan) and hold the remainder unevaluated. Sorting only matters past the - // cap, so small candidate sets keep their original order and cost nothing. let evaluate_count = actions.len().min(MAX_ML_EVALUATED_ACTIONS); if actions.len() > MAX_ML_EVALUATED_ACTIONS { actions.sort_by_key(|action| std::cmp::Reverse(action.reclaimable_bytes)); } + // Scope context accumulation and entry lookup strictly to the evaluated + // candidate targets and their ancestors instead of materializing maps over + // the entire million-entry scan. + let mut wanted_dirs: HashSet = HashSet::new(); + let mut targets: HashSet<&Path> = HashSet::with_capacity(evaluate_count); + for action in actions.iter().take(evaluate_count) { + targets.insert(action.target.as_path()); + if let Some(parent) = action.target.parent() { + for ancestor in parent.ancestors() { + if !wanted_dirs.insert(ancestor.to_path_buf()) { + break; + } + } + } + } + + let groups = accumulate_dirs(scan, |parent| wanted_dirs.contains(parent)); + let entries: HashMap<&Path, &FileEntry> = scan + .entries + .iter() + .filter(|entry| targets.contains(entry.path.as_path())) + .map(|entry| (entry.path.as_path(), entry)) + .collect(); + let mut result = Adjusted::default(); for (index, mut action) in actions.into_iter().enumerate() { let annotation = if index < evaluate_count { - annotate(classifier, threshold, &action, &entries, &contexts) + annotate(classifier, threshold, &action, &entries, &groups) } else { - // Past the inference cap: stays executable (recipes are trusted), - // just without an ML badge. ML is advisory and never gates the plan. ActionAnnotation::not_evaluated("not ML-scored (batch cap); recipe trusted") }; - // Surface the label+confidence on the action itself (existing UIs render - // `reason`), so no UI change is needed to display the ML verdict. if let (Some(label), Some(confidence)) = (annotation.ml_label, annotation.ml_confidence) { action.reason = format!("{} [ML {label}: {confidence:.2}]", action.reason); } @@ -202,7 +215,7 @@ fn annotate( threshold: f32, action: &CleanupAction, entries: &HashMap<&Path, &FileEntry>, - contexts: &HashMap, + groups: &HashMap, ) -> ActionAnnotation { let Some(entry) = entries.get(action.target.as_path()) else { return ActionAnnotation::not_evaluated("no scan entry for target; left unchanged"); @@ -211,9 +224,7 @@ fn annotate( let context = action .target .parent() - .and_then(|parent| contexts.get(parent)) - .cloned() - .unwrap_or_else(empty_context); + .map_or_else(empty_context, |parent| context_for(parent, groups)); match classifier.classify(entry, &context) { Ok(classification) => decide(classification.label, classification.confidence, threshold), @@ -431,6 +442,7 @@ fn context_for(parent: &Path, groups: &HashMap) -> FileContext /// the scan data alone (no extra filesystem access). One O(n) accumulation /// pass plus an O(depth) ancestor walk per distinct parent — no quadratic /// sibling scans, so million-entry scans stay linear. +#[cfg(test)] fn build_contexts(scan: &ScanResult) -> HashMap { let groups = accumulate_dirs(scan, |_| true); groups diff --git a/crates/cleanton-engine/src/orchestrator.rs b/crates/cleanton-engine/src/orchestrator.rs index 3fd88ca..a08a7f4 100644 --- a/crates/cleanton-engine/src/orchestrator.rs +++ b/crates/cleanton-engine/src/orchestrator.rs @@ -132,6 +132,10 @@ impl Orchestrator { #[must_use] pub fn analyze(&self, scan: &ScanResult) -> AnalyzedPlan { let classifier = MlEngine::embedded(); + self.analyze_with_classifier(scan, &classifier) + } + + fn analyze_with_classifier(&self, scan: &ScanResult, classifier: &MlEngine) -> AnalyzedPlan { // Only inspect ~/Library for orphaned app data when the scan actually // covered it (home / full-disk scan). Skips the expensive Library walk // entirely for project-scoped scans. @@ -144,7 +148,7 @@ impl Orchestrator { &self.config, scan, &self.recipes, - &classifier, + classifier, DEFAULT_ML_CONFIDENCE_THRESHOLD, orphans, ) @@ -155,14 +159,19 @@ impl Orchestrator { /// review-only [`Suggestions`]. Docker failures degrade gracefully to an /// empty analysis so a missing/absent daemon never breaks the Results view. pub async fn analyze_full(&self, scan: &ScanResult) -> AnalyzedFull { - let analyzed = self.analyze(scan); - let docker = analyze_docker().await; - let mut suggestions = detect_suggestions(scan); - // Advisory ML badges on the review-only suggestion items (display - // only; suggestions are never auto-removed and this never changes - // what is suggested). let classifier = MlEngine::embedded(); - crate::ml::annotate_suggestions(&classifier, &mut suggestions, scan); + let cpu_work = async { + std::thread::scope(|scope| { + let analyzed_handle = scope.spawn(|| self.analyze_with_classifier(scan, &classifier)); + let mut suggestions = detect_suggestions(scan); + crate::ml::annotate_suggestions(&classifier, &mut suggestions, scan); + let analyzed = analyzed_handle + .join() + .unwrap_or_else(|_| self.analyze_with_classifier(scan, &classifier)); + (analyzed, suggestions) + }) + }; + let (docker, (analyzed, suggestions)) = tokio::join!(analyze_docker(), cpu_work); AnalyzedFull { analyzed, docker, diff --git a/crates/cleanton-engine/src/security.rs b/crates/cleanton-engine/src/security.rs index ec00693..134a27e 100644 --- a/crates/cleanton-engine/src/security.rs +++ b/crates/cleanton-engine/src/security.rs @@ -1612,6 +1612,16 @@ fn push_host_persistence_targets(targets: &mut Vec, home: &Path) SecurityTargetKind::HostPersistence, home.join(".config/systemd/user"), ); + push_if_dir( + targets, + SecurityTargetKind::HostPersistence, + home.join(".config/autostart"), + ); + push_if_dir( + targets, + SecurityTargetKind::HostPersistence, + home.join(".config/fish"), + ); push_if_dir( targets, SecurityTargetKind::HostPersistence, @@ -1800,6 +1810,26 @@ fn enumerate_python_packages(target: &SecurityTarget, out: &mut Vec-.dist-info` directories once per `site-packages` + // root instead of re-reading the directory for every package. + let mut dist_versions: HashMap = HashMap::new(); + for path in &paths { + if !path.is_dir() { + continue; + } + let Some(file_name) = path.file_name().map(|n| n.to_string_lossy().into_owned()) else { + continue; + }; + if let Some(stem) = file_name.strip_suffix(".dist-info") + && let Some((dist_name, version)) = stem.rsplit_once('-') + { + dist_versions + .entry(dist_name.to_lowercase().replace('-', "_")) + .or_insert_with(|| version.to_owned()); + } + } + for path in paths { let Some(file_name) = path.file_name().map(|n| n.to_string_lossy().into_owned()) else { continue; @@ -1828,7 +1858,8 @@ fn enumerate_python_packages(target: &SecurityTarget, out: &mut Vec extension.as_deref() == Some("plist"), PersistenceRoot::SystemdUser => extension.as_deref() == Some("service"), + PersistenceRoot::DesktopAutostart => extension.as_deref() == Some("desktop"), + PersistenceRoot::FishStartup => lower == "config.fish", PersistenceRoot::ShellStartup => PERSISTENCE_FILE_NAMES.contains(&lower.as_str()), }; if !wanted || !path.is_file() { @@ -2441,6 +2482,16 @@ pub fn scan_targets_with_options( let total = packages.len(); progress(SecurityProgress { scanned: 0, total }); + let preloaded_baseline = options + .baseline_path + .as_deref() + .map(security_baseline::load); + let skip_unchanged_av = !options.establish_baseline + && !options.include_unchanged + && preloaded_baseline + .as_ref() + .is_some_and(|baseline| !baseline.is_empty()); + let scanner = Scanner::with_default_engines(scan_config()); let next = AtomicUsize::new(0); let done_count = AtomicUsize::new(0); @@ -2485,6 +2536,51 @@ pub fn scan_targets_with_options( } }; let advisories = advisory_matches(&database, candidate); + + // Fast path in diff mode: when a package is byte-identical to + // its trusted baseline entry and matches no advisories, skip + // running all 11 content engines on its tree. + if skip_unchanged_av + && advisories.is_empty() + && !fingerprint.is_empty() + && let Some(baseline) = preloaded_baseline.as_ref() + && let Some(entry) = baseline.entries.get( + &security_baseline::identity_key(candidate.source, &candidate.name), + ) + && entry.fingerprint == fingerprint + { + let verdict = match entry.verdict_at_baseline.as_str() { + "malicious" => AvVerdict::Malicious, + "suspicious" => AvVerdict::Suspicious, + _ => AvVerdict::Clean, + }; + let package_report = PackageReport { + name: candidate.name.clone(), + version: candidate.version.clone(), + path: candidate.display_path.clone(), + source: candidate.source, + verdict, + change: ChangeKind::Uncompared, + assessment: assess(ChangeKind::Uncompared, verdict, false), + corroborated: false, + fingerprint, + score: 0, + top_findings: Vec::new(), + finding_count: 0, + }; + let mut guard = match results.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + guard.push(package_report); + let done = done_count.fetch_add(1, Ordering::Relaxed).saturating_add(1); + progress(SecurityProgress { + scanned: done, + total, + }); + continue; + } + match scanner.scan_path(&candidate.scan_path) { Ok(report) => { let package_report = @@ -2545,7 +2641,7 @@ pub fn scan_targets_with_options( incomplete: cancelled.load(Ordering::Relaxed), ..SecurityReport::empty() }; - apply_baseline(&mut report, results, options); + apply_baseline(&mut report, results, options, preloaded_baseline); report.duration = started.elapsed(); report } @@ -2556,6 +2652,7 @@ fn apply_baseline( report: &mut SecurityReport, scanned: Vec, options: &SecurityScanOptions, + preloaded_baseline: Option, ) { let Some(baseline_path) = options.baseline_path.as_deref() else { // Raw mode (no baseline available): every non-clean AV verdict is @@ -2573,7 +2670,7 @@ fn apply_baseline( }; report.baseline_used = true; - let existing = security_baseline::load(baseline_path); + let existing = preloaded_baseline.unwrap_or_else(|| security_baseline::load(baseline_path)); let establish = options.establish_baseline || existing.is_empty(); if establish && !report.incomplete { diff --git a/crates/cleanton-engine/src/suggestions.rs b/crates/cleanton-engine/src/suggestions.rs index 5ce81d6..e18cc41 100644 --- a/crates/cleanton-engine/src/suggestions.rs +++ b/crates/cleanton-engine/src/suggestions.rs @@ -676,9 +676,9 @@ fn detect_large_items_with_probe( }); } - // Subtree sizes: propagate each file's bytes up to every scanned ancestor - // directory, mirroring the in-memory ancestor walk in `analyzer.rs`. - // O(entries × depth) over in-memory data; no extra disk walks. + // Subtree sizes: credit each file to its nearest scanned ancestor directory, + // then reduce directories bottom-up so dense scans pay O(entries) instead of + // O(entries * depth) while sparse test fixtures still fold accurately. let dirs: HashSet<&Path> = scan .entries .iter() @@ -691,17 +691,37 @@ fn detect_large_items_with_probe( if entry.kind != FileKind::File { continue; } - for ancestor in entry.path.ancestors() { - if !dirs.contains(ancestor) { - continue; - } + if let Some(nearest_dir) = entry + .path + .ancestors() + .skip(1) + .find(|ancestor| dirs.contains(ancestor)) + { subtree - .entry(ancestor) + .entry(nearest_dir) .and_modify(|total| *total = total.saturating_add(entry.logical_bytes)) .or_insert(entry.logical_bytes); } } + let mut ordered_dirs: Vec<&Path> = dirs.iter().copied().collect(); + ordered_dirs.sort_unstable_by_key(|dir| std::cmp::Reverse(dir.components().count())); + for dir in ordered_dirs { + let Some(bytes) = subtree.get(dir).copied() else { + continue; + }; + if let Some(parent_dir) = dir + .ancestors() + .skip(1) + .find(|ancestor| dirs.contains(ancestor)) + { + subtree + .entry(parent_dir) + .and_modify(|total| *total = total.saturating_add(bytes)) + .or_insert(bytes); + } + } + // Large user-data folders (existing behavior): excluding the user-data roots // themselves (rel len 1), which are trivially large; only subfolders are // interesting. These are plain folders, not re-downloadable. diff --git a/crates/cleanton-recipes/src/builtin.rs b/crates/cleanton-recipes/src/builtin.rs index 7e0312d..ce29470 100644 --- a/crates/cleanton-recipes/src/builtin.rs +++ b/crates/cleanton-recipes/src/builtin.rs @@ -611,4 +611,76 @@ pub(crate) const BUILTIN_RECIPES: &[(&str, &str)] = &[ "recipes/ocaml-dune-cache.toml", include_str!("../../../recipes/ocaml-dune-cache.toml"), ), + ( + "recipes/dotnet-obj-dirs.toml", + include_str!("../../../recipes/dotnet-obj-dirs.toml"), + ), + ( + "recipes/dotnet-http-cache.toml", + include_str!("../../../recipes/dotnet-http-cache.toml"), + ), + ( + "recipes/jvm-maven-target-dirs.toml", + include_str!("../../../recipes/jvm-maven-target-dirs.toml"), + ), + ( + "recipes/jvm-project-gradle-dirs.toml", + include_str!("../../../recipes/jvm-project-gradle-dirs.toml"), + ), + ( + "recipes/jvm-scala-build-dirs.toml", + include_str!("../../../recipes/jvm-scala-build-dirs.toml"), + ), + ( + "recipes/clojure-build-caches.toml", + include_str!("../../../recipes/clojure-build-caches.toml"), + ), + ( + "recipes/node-modern-framework-dirs.toml", + include_str!("../../../recipes/node-modern-framework-dirs.toml"), + ), + ( + "recipes/node-nx-cache-dirs.toml", + include_str!("../../../recipes/node-nx-cache-dirs.toml"), + ), + ( + "recipes/c-cpp-build-variants.toml", + include_str!("../../../recipes/c-cpp-build-variants.toml"), + ), + ( + "recipes/c-cpp-vcpkg-cache.toml", + include_str!("../../../recipes/c-cpp-vcpkg-cache.toml"), + ), + ( + "recipes/dart-tool-build-dirs.toml", + include_str!("../../../recipes/dart-tool-build-dirs.toml"), + ), + ( + "recipes/haskell-cabal-build-dirs.toml", + include_str!("../../../recipes/haskell-cabal-build-dirs.toml"), + ), + ( + "recipes/ocaml-dune-build-dirs.toml", + include_str!("../../../recipes/ocaml-dune-build-dirs.toml"), + ), + ( + "recipes/android-build-caches.toml", + include_str!("../../../recipes/android-build-caches.toml"), + ), + ( + "recipes/godot-import-cache.toml", + include_str!("../../../recipes/godot-import-cache.toml"), + ), + ( + "recipes/php-tool-caches.toml", + include_str!("../../../recipes/php-tool-caches.toml"), + ), + ( + "recipes/ide-vscode-extension-vsixs.toml", + include_str!("../../../recipes/ide-vscode-extension-vsixs.toml"), + ), + ( + "recipes/ide-editor-caches.toml", + include_str!("../../../recipes/ide-editor-caches.toml"), + ), ]; diff --git a/crates/cleanton-recipes/src/lib.rs b/crates/cleanton-recipes/src/lib.rs index 61ba383..270efdf 100644 --- a/crates/cleanton-recipes/src/lib.rs +++ b/crates/cleanton-recipes/src/lib.rs @@ -5,7 +5,7 @@ mod registry; mod validator; pub use matcher::{ - DirectoryGate, action_for_entry, action_for_entry_with_context, + CompiledRecipeMatcher, DirectoryGate, action_for_entry, action_for_entry_with_context, action_for_entry_with_subtree_mtime, directory_gate, directory_gate_with_context, recipe_has_gates, recipe_matches_entry, }; diff --git a/crates/cleanton-recipes/src/matcher.rs b/crates/cleanton-recipes/src/matcher.rs index 3d06918..93e6598 100644 --- a/crates/cleanton-recipes/src/matcher.rs +++ b/crates/cleanton-recipes/src/matcher.rs @@ -511,12 +511,567 @@ const SENSITIVE_BASENAMES: &[&str] = &[ ]; fn is_sensitive_basename(path: &Path) -> bool { - path.file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| { - let lower = name.to_ascii_lowercase(); - SENSITIVE_BASENAMES.iter().any(|item| *item == lower) + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + return false; + }; + // Every basename in SENSITIVE_BASENAMES is <= 20 ASCII bytes; stack-lowercasing + // avoids allocating a heap String for every scanned file on the hot path. + if name.is_empty() || name.len() > 32 || !name.is_ascii() { + return false; + } + let mut buf = [0_u8; 32]; + let bytes = name.as_bytes(); + for (dst, src) in buf.iter_mut().zip(bytes.iter()) { + *dst = src.to_ascii_lowercase(); + } + let Ok(lower) = std::str::from_utf8(buf.get(..bytes.len()).unwrap_or(&[])) else { + return false; + }; + SENSITIVE_BASENAMES.contains(&lower) +} + +#[derive(Debug)] +enum CompiledNameMatcher { + Exact(String), + Glob(Pattern), +} + +impl CompiledNameMatcher { + fn compile(raw: &str) -> Option { + if !contains_glob_meta(raw) { + Some(Self::Exact(raw.to_owned())) + } else { + Pattern::new(raw).ok().map(Self::Glob) + } + } + + fn matches(&self, name: &str) -> bool { + match self { + Self::Exact(expected) => name == expected, + Self::Glob(pattern) => pattern.matches(name), + } + } +} + +#[derive(Debug)] +struct CompiledPathGlob { + full: Option, + stripped_dir_base: Option, + literal_prefix: Option, + required_substring: Option, +} + +impl CompiledPathGlob { + fn compile(raw: &str, min_age_days: Option) -> Self { + let expanded = if raw.starts_with('~') { + expand_tilde(&PathBuf::from(raw)) + .to_string_lossy() + .into_owned() + } else { + raw.to_owned() + }; + let full = Pattern::new(&expanded).ok(); + let stripped_dir_base = if min_age_days.is_none() { + expanded + .strip_suffix("/**") + .and_then(|base| Pattern::new(base).ok()) + } else { + None + }; + let effective_for_prefix = expanded.strip_suffix("/**").unwrap_or(&expanded); + let literal_prefix = extract_literal_prefix(effective_for_prefix); + let required_substring = if literal_prefix.is_none() { + extract_longest_literal_segment(effective_for_prefix) + } else { + None + }; + Self { + full, + stripped_dir_base, + literal_prefix, + required_substring, + } + } + + #[inline] + fn matches_entry(&self, entry: &FileEntry) -> bool { + if let Some(prefix) = &self.literal_prefix + && !entry.path.starts_with(prefix) + { + return false; + } + if let Some(required) = &self.required_substring { + let path_lossy = entry.path.to_string_lossy(); + if !path_lossy.contains(required.as_str()) { + return false; + } + } + if self + .full + .as_ref() + .is_some_and(|pattern| pattern.matches_path(&entry.path)) + { + return true; + } + entry.kind == FileKind::Directory + && self + .stripped_dir_base + .as_ref() + .is_some_and(|base| base.matches_path(&entry.path)) + } +} + +fn contains_glob_meta(text: &str) -> bool { + text.bytes().any(|b| matches!(b, b'*' | b'?' | b'[' | b']')) +} + +fn extract_literal_prefix(pattern: &str) -> Option { + if pattern.is_empty() || pattern.starts_with("./") || pattern.contains('\\') { + return None; + } + let leading_slash = pattern.starts_with('/'); + let mut prefix = if leading_slash { + PathBuf::from("/") + } else { + PathBuf::new() + }; + let mut count = 0_usize; + for part in pattern.split('/') { + if part.is_empty() { + continue; + } + if part == "." || part == ".." || contains_glob_meta(part) { + break; + } + prefix.push(part); + count = count.saturating_add(1); + } + (count > 0).then_some(prefix) +} + +fn extract_longest_literal_segment(pattern: &str) -> Option { + pattern + .split('/') + .filter(|part| { + !part.is_empty() && *part != "." && *part != ".." && !contains_glob_meta(part) }) + .max_by_key(|part| part.len()) + .filter(|part| part.len() >= 2) + .map(str::to_owned) +} + +#[derive(Debug)] +struct CompiledSpec { + exclude_names: Vec, + exclude_paths: Vec>, + directory_name: Option, + file_name: Option>, + file_extensions: Option>, + path_glob: Option, + parent_must_contain: Option>, + parent_must_contain_any: Option>, + self_must_contain: Option>, + min_size_bytes: Option, + min_age_days: Option, +} + +impl CompiledSpec { + fn compile(spec: &crate::MatchSpec) -> Self { + let exclude_paths = spec + .exclude + .paths + .iter() + .map(|raw| { + let expanded = if raw.starts_with('~') { + expand_tilde(&PathBuf::from(raw)) + .to_string_lossy() + .into_owned() + } else { + raw.clone() + }; + Pattern::new(&expanded).ok() + }) + .collect(); + let file_name = spec + .file_name + .as_deref() + .map(CompiledNameMatcher::compile); + let file_extensions = spec.file_extension.as_ref().map(|exts| { + exts.iter() + .map(|ext| ext.strip_prefix('.').unwrap_or(ext).to_owned()) + .collect() + }); + let path_glob = spec + .path_glob + .as_deref() + .map(|raw| CompiledPathGlob::compile(raw, spec.min_age_days)); + + Self { + exclude_names: spec.exclude.names.clone(), + exclude_paths, + directory_name: spec.directory_name.clone(), + file_name, + file_extensions, + path_glob, + parent_must_contain: spec.parent_must_contain.clone(), + parent_must_contain_any: spec.parent_must_contain_any.clone(), + self_must_contain: spec.self_must_contain.clone(), + min_size_bytes: spec.min_size_mb.map(|mb| mb.saturating_mul(1_048_576)), + min_age_days: spec.min_age_days, + } + } + + fn selects_entry( + &self, + entry: &FileEntry, + entry_name: Option<&str>, + entry_ext: Option<&str>, + dir_contains: &impl Fn(&Path, &str) -> bool, + ) -> bool { + if let Some( expected ) = &self.directory_name { + if entry.kind != FileKind::Directory || entry_name != Some(expected.as_str()) { + return false; + } + } + if let Some(compiled_name) = &self.file_name { + if entry.kind != FileKind::File { + return false; + } + let Some(matcher) = compiled_name.as_ref() else { + return false; + }; + let Some(actual) = entry_name else { + return false; + }; + if !matcher.matches(actual) { + return false; + } + } + if let Some(extensions) = &self.file_extensions { + if entry.kind != FileKind::File { + return false; + } + let Some(ext) = entry_ext else { + return false; + }; + if !extensions.iter().any(|item| item == ext) { + return false; + } + } + if !self.exclude_names.is_empty() + && entry + .path + .file_name() + .is_some_and(|candidate| self.exclude_names.iter().any(|n| candidate == n.as_str())) + { + return false; + } + if self.exclude_paths.iter().any(|pat| { + pat.as_ref() + .is_some_and(|compiled| compiled.matches_path(&entry.path)) + }) { + return false; + } + if let Some(glob) = &self.path_glob + && !glob.matches_entry(entry) + { + return false; + } + if let Some(markers) = &self.parent_must_contain { + let Some(parent) = entry.path.parent() else { + return false; + }; + if !markers.iter().all(|marker| dir_contains(parent, marker)) { + return false; + } + } + if let Some(markers) = &self.parent_must_contain_any { + let Some(parent) = entry.path.parent() else { + return false; + }; + if !markers.iter().any(|marker| dir_contains(parent, marker)) { + return false; + } + } + if let Some(markers) = &self.self_must_contain { + if entry.kind != FileKind::Directory { + return false; + } + if !markers + .iter() + .all(|marker| dir_contains(&entry.path, marker)) + { + return false; + } + } + true + } + + fn gates_pass( + &self, + entry: &FileEntry, + newest_in_subtree: &impl Fn(&Path) -> Option, + ) -> bool { + if let Some(min_bytes) = self.min_size_bytes + && entry.allocated_bytes < min_bytes + { + return false; + } + if let Some(min_age_days) = self.min_age_days { + let modified = if entry.kind == FileKind::Directory { + newest_in_subtree(&entry.path).or(entry.modified) + } else { + entry.modified + }; + if !is_old_enough(modified, min_age_days) { + return false; + } + } + true + } +} + +#[derive(Debug)] +struct CompiledRecipe<'a> { + doc: &'a RecipeDocument, + has_gates: bool, + specs: Vec, +} + +/// Lock-free, precompiled, indexed matcher over an ordered slice of recipes. +/// +/// Built once per analysis pass so bulk matching avoids all `RwLock` contention, +/// redundant `ProtectedPaths` / sensitive-basename checks, and irrelevant glob +/// evaluations. +#[derive(Debug)] +pub struct CompiledRecipeMatcher<'a> { + recipes: Vec>, + home_dir: Option, + by_dir_name: HashMap>, + by_file_ext: HashMap>, + by_home_top_dir: HashMap>, + fallback_dirs: Vec, + fallback_files: Vec, +} + +impl<'a> CompiledRecipeMatcher<'a> { + #[must_use] + pub fn new(ordered: &[&'a RecipeDocument]) -> Self { + let home_dir = std::env::var_os("HOME") + .map(PathBuf::from) + .filter(|p| p.is_absolute()); + let mut recipes = Vec::with_capacity(ordered.len()); + let mut by_dir_name: HashMap> = HashMap::new(); + let mut by_file_ext: HashMap> = HashMap::new(); + let mut by_home_top_dir: HashMap> = HashMap::new(); + let mut fallback_dirs: Vec = Vec::new(); + let mut fallback_files: Vec = Vec::new(); + + for (recipe_idx, &doc) in ordered.iter().enumerate() { + let specs = std::iter::once(&doc.matcher) + .chain(doc.matcher.any_of.iter()) + .map(CompiledSpec::compile) + .collect::>(); + let has_gates = recipe_has_gates(doc); + + for spec in &specs { + if let Some(dir_name) = &spec.directory_name { + push_unique(by_dir_name.entry(dir_name.clone()).or_default(), recipe_idx); + continue; + } + if let Some(exts) = &spec.file_extensions { + for ext in exts { + push_unique(by_file_ext.entry(ext.clone()).or_default(), recipe_idx); + } + continue; + } + if let Some(top) = home_dir + .as_deref() + .and_then(|home| spec_home_top_component(spec, home)) + { + push_unique(by_home_top_dir.entry(top).or_default(), recipe_idx); + continue; + } + if spec.file_name.is_none() { + push_unique(&mut fallback_dirs, recipe_idx); + } + push_unique(&mut fallback_files, recipe_idx); + } + + recipes.push(CompiledRecipe { + doc, + has_gates, + specs, + }); + } + + Self { + recipes, + home_dir, + by_dir_name, + by_file_ext, + by_home_top_dir, + fallback_dirs, + fallback_files, + } + } + + #[must_use] + pub fn len(&self) -> usize { + self.recipes.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.recipes.is_empty() + } + + /// Return the indices of gated recipes whose directory selector matched `entry` + /// (`FileKind::Directory`) while every matching spec failed its size/age gate. + pub fn gate_failed_recipe_indices( + &self, + entry: &FileEntry, + candidates_buf: &mut Vec, + dir_contains: impl Fn(&Path, &str) -> bool, + newest_in_subtree: impl Fn(&Path) -> Option, + ) -> Vec { + if entry.kind != FileKind::Directory + || DEFAULT_PROTECTED.is_protected_fast(&entry.path) + || is_sensitive_basename(&entry.path) + { + return Vec::new(); + } + let entry_name = entry.path.file_name().map(|n| n.to_string_lossy()); + self.collect_candidates(entry, entry_name.as_deref(), None, candidates_buf); + + let mut failed = Vec::new(); + for &recipe_idx in candidates_buf.iter() { + let Some(compiled) = self.recipes.get(recipe_idx) else { + continue; + }; + if !compiled.has_gates { + continue; + } + let mut gate_failed = false; + let mut matched = false; + for spec in &compiled.specs { + if !spec.selects_entry(entry, entry_name.as_deref(), None, &dir_contains) { + continue; + } + if spec.gates_pass(entry, &newest_in_subtree) { + matched = true; + break; + } + gate_failed = true; + } + if !matched && gate_failed { + failed.push(recipe_idx); + } + } + failed + } + + /// Evaluate candidate recipes in priority order and return the first matching + /// [`CleanupAction`], skipping any recipe for which `is_suppressed(recipe_idx)` is true. + pub fn first_matching_action( + &self, + entry: &FileEntry, + candidates_buf: &mut Vec, + is_suppressed: impl Fn(usize) -> bool, + dir_contains: impl Fn(&Path, &str) -> bool, + newest_in_subtree: impl Fn(&Path) -> Option, + ) -> Option { + if DEFAULT_PROTECTED.is_protected_fast(&entry.path) || is_sensitive_basename(&entry.path) { + return None; + } + let entry_name = entry.path.file_name().map(|n| n.to_string_lossy()); + let entry_ext = if entry.kind == FileKind::File { + entry.path.extension().map(|e| e.to_string_lossy()) + } else { + None + }; + self.collect_candidates( + entry, + entry_name.as_deref(), + entry_ext.as_deref(), + candidates_buf, + ); + + for &recipe_idx in candidates_buf.iter() { + if is_suppressed(recipe_idx) { + continue; + } + let Some(compiled) = self.recipes.get(recipe_idx) else { + continue; + }; + let hit = compiled.specs.iter().any(|spec| { + spec.selects_entry( + entry, + entry_name.as_deref(), + entry_ext.as_deref(), + &dir_contains, + ) && spec.gates_pass(entry, &newest_in_subtree) + }); + if hit { + return Some(build_action(compiled.doc, entry)); + } + } + None + } + + fn collect_candidates( + &self, + entry: &FileEntry, + entry_name: Option<&str>, + entry_ext: Option<&str>, + out: &mut Vec, + ) { + out.clear(); + match entry.kind { + FileKind::Directory => { + if let Some(name) = entry_name + && let Some(indices) = self.by_dir_name.get(name) + { + out.extend_from_slice(indices); + } + out.extend_from_slice(&self.fallback_dirs); + } + FileKind::File => { + if let Some(ext) = entry_ext + && let Some(indices) = self.by_file_ext.get(ext) + { + out.extend_from_slice(indices); + } + out.extend_from_slice(&self.fallback_files); + } + FileKind::Symlink | FileKind::Other => { + out.extend_from_slice(&self.fallback_files); + } + } + if let Some(home) = &self.home_dir + && let Ok(rel) = entry.path.strip_prefix(home) + && let Some(first) = rel.components().next() + && let Some(top) = first.as_os_str().to_str() + && let Some(indices) = self.by_home_top_dir.get(top) + { + out.extend_from_slice(indices); + } + out.sort_unstable(); + out.dedup(); + } +} + +fn push_unique(vec: &mut Vec, idx: usize) { + if vec.last().copied() != Some(idx) && !vec.contains(&idx) { + vec.push(idx); + } +} + +fn spec_home_top_component(spec: &CompiledSpec, home: &Path) -> Option { + let prefix = spec.path_glob.as_ref()?.literal_prefix.as_ref()?; + let rel = prefix.strip_prefix(home).ok()?; + let first = rel.components().next()?; + first.as_os_str().to_str().map(str::to_owned) } fn is_old_enough(modified: Option, min_age_days: u64) -> bool { @@ -913,4 +1468,58 @@ mod tests { // Now it should match, because all files in the subtree are old assert!(recipe_matches_entry(&recipe, &entry)); } + + #[test] + fn compiled_matcher_matches_sequential_builtin_behavior() { + let set = RecipeSet::load_builtin().expect("builtin recipes"); + let mut ordered = set + .iter() + .filter(|doc| doc.recipe.enabled) + .collect::>(); + ordered.sort_by_key(|doc| std::cmp::Reverse(doc.recipe.priority)); + let compiled = CompiledRecipeMatcher::new(&ordered); + + let sample_paths = [ + ("~/Library/Caches/pip", FileKind::Directory), + ("~/Library/Caches/pip/http/wheel.whl", FileKind::File), + ("~/.cache/pip/http/wheel.whl", FileKind::File), + ("~/Projects/app/__pycache__", FileKind::Directory), + ("~/Projects/app/__pycache__/mod.cpython-312.pyc", FileKind::File), + ("~/Projects/app/target", FileKind::Directory), + ("~/Projects/app/node_modules", FileKind::Directory), + ("~/Projects/app/src/main.rs", FileKind::File), + ("~/Projects/app/.env", FileKind::File), + ("~/Documents/secret/target", FileKind::Directory), + ("~/Models/llama-3.gguf", FileKind::File), + ]; + + let mut buf = Vec::new(); + for (raw, kind) in sample_paths { + let mut candidate = test_entry(expand_tilde(Path::new(raw)), kind); + candidate.allocated_bytes = 256 * 1_048_576; + candidate.logical_bytes = candidate.allocated_bytes; + candidate.modified = Some(SystemTime::now() - Duration::from_hours(24 * 120)); + + let marker_fn = |_: &Path, marker: &str| { + matches!(marker, "Cargo.toml" | "package.json" | "pyproject.toml") + }; + let mtime_fn = |_: &Path| candidate.modified; + + let expected = ordered.iter().copied().find_map(|recipe| { + action_for_entry_with_context(recipe, &candidate, marker_fn, mtime_fn) + }); + let actual = compiled.first_matching_action( + &candidate, + &mut buf, + |_| false, + marker_fn, + mtime_fn, + ); + assert_eq!( + actual.as_ref().map(|a| &a.recipe_id), + expected.as_ref().map(|a| &a.recipe_id), + "mismatch for {raw}" + ); + } + } } diff --git a/crates/cleanton-recipes/src/validator.rs b/crates/cleanton-recipes/src/validator.rs index e612f6e..cd9367d 100644 --- a/crates/cleanton-recipes/src/validator.rs +++ b/crates/cleanton-recipes/src/validator.rs @@ -518,7 +518,7 @@ mod tests { // pinned deliberately: adding a recipe without registering it (or the // reverse) is exactly the mistake this assertion catches, so it must be // bumped by hand. - assert_eq!(recipes.len(), 153); + assert_eq!(recipes.len(), 171); } #[test] diff --git a/crates/cleanton-scanner/src/capacity_ffi.rs b/crates/cleanton-scanner/src/capacity_ffi.rs index 8b285cb..b8874a8 100644 --- a/crates/cleanton-scanner/src/capacity_ffi.rs +++ b/crates/cleanton-scanner/src/capacity_ffi.rs @@ -223,6 +223,24 @@ mod tests { let _ = is_case_sensitive(Path::new("/")); let _ = is_case_sensitive(Path::new("/definitely-missing-cleanton-volume-xyz")); } + + #[test] + fn parses_linux_mem_available_from_proc_meminfo() { + let sample = "MemTotal: 32768000 kB\nMemFree: 1024000 kB\nMemAvailable: 24576000 kB\n"; + assert_eq!(parse_linux_mem_available(sample), Some(24_576_000 * 1024)); + assert_eq!(parse_linux_mem_available("MemTotal: 1000 kB\n"), None); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn memory_helpers_report_valid_ranges() { + let mem = memory_usage().expect("memory usage available on unix CI"); + assert!(mem.total_bytes > 0); + assert!(mem.available_bytes <= mem.total_bytes); + assert!((0.0..=1.0).contains(&mem.used_fraction)); + let _ = top_memory_processes(5); + let _ = reclaim_process_memory(); + } } /// System memory usage snapshot (total / available bytes + used fraction). @@ -255,7 +273,7 @@ pub fn memory_usage() -> Option { if total_bytes == 0 { return None; } - let available_bytes = available_bytes(page)?; + let available_bytes = available_bytes(page)?.min(total_bytes); let used = total_bytes.saturating_sub(available_bytes); #[allow(clippy::cast_precision_loss)] let used_fraction = used as f64 / total_bytes as f64; @@ -268,6 +286,15 @@ pub fn memory_usage() -> Option { #[cfg(target_os = "linux")] fn available_bytes(page: u64) -> Option { + // Prefer `/proc/meminfo` `MemAvailable:` so reclaimable kernel page cache and + // buffers are counted as available memory instead of reporting false pressure + // when Linux caches filesystem reads in free RAM. + if let Ok(meminfo) = std::fs::read_to_string("/proc/meminfo") + && let Some(bytes) = parse_linux_mem_available(&meminfo) + { + return Some(bytes); + } + // SAFETY: sysconf with a valid name returns -1 on error and has no // memory-safety requirements; the result is checked before use. let avail_pages = unsafe { libc::sysconf(libc::_SC_AVPHYS_PAGES) }; @@ -277,9 +304,23 @@ fn available_bytes(page: u64) -> Option { Some(u64::try_from(avail_pages).ok()?.saturating_mul(page)) } +#[cfg(any(target_os = "linux", test))] +fn parse_linux_mem_available(meminfo: &str) -> Option { + for line in meminfo.lines() { + if let Some(rest) = line.strip_prefix("MemAvailable:") { + let kb_str = rest.split_whitespace().next()?; + let kb = kb_str.parse::().ok()?; + return Some(kb.saturating_mul(1024)); + } + } + None +} + #[cfg(target_os = "macos")] #[allow(deprecated)] // libc::mach_host_self: the mach2 alternative is not a dependency. fn available_bytes(page: u64) -> Option { + // SAFETY: `vm_statistics64` is a plain-old-data Mach struct of integer counters + // for which an all-zero bit pattern is valid. let mut stats: libc::vm_statistics64 = unsafe { std::mem::zeroed() }; let mut count = u32::try_from( std::mem::size_of::() / std::mem::size_of::(), @@ -300,8 +341,9 @@ fn available_bytes(page: u64) -> Option { if rc != libc::KERN_SUCCESS { return None; } - let available_pages = - u64::from(stats.free_count).saturating_add(u64::from(stats.inactive_count)); + let available_pages = u64::from(stats.free_count) + .saturating_add(u64::from(stats.inactive_count)) + .saturating_add(u64::from(stats.speculative_count)); Some(available_pages.saturating_mul(page)) } @@ -309,3 +351,240 @@ fn available_bytes(page: u64) -> Option { fn available_bytes(_page: u64) -> Option { None } + +/// Snapshot of one user-owned process's resident memory usage. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProcessMemorySample { + pub pid: u32, + pub name: String, + pub rss_bytes: u64, +} + +/// Return up to `limit` user-owned processes with the largest resident set size (`RSS`), +/// excluding the current Cleanton process. Pure read-only inspection; never signals or +/// mutates any process. +#[must_use] +pub fn top_memory_processes(limit: usize) -> Vec { + if limit == 0 { + return Vec::new(); + } + let mut samples = collect_user_process_memory(); + samples.sort_by(|a, b| { + b.rss_bytes + .cmp(&a.rss_bytes) + .then_with(|| a.name.cmp(&b.name)) + .then_with(|| a.pid.cmp(&b.pid)) + }); + samples.truncate(limit); + samples +} + +#[cfg(target_os = "linux")] +fn collect_user_process_memory() -> Vec { + use std::os::unix::fs::MetadataExt; + + // SAFETY: `geteuid` has no preconditions and cannot cause undefined behavior. + let current_uid = unsafe { libc::geteuid() }; + // SAFETY: `sysconf(_SC_PAGESIZE)` has no preconditions; negative values are rejected. + let page_raw = unsafe { libc::sysconf(libc::_SC_PAGESIZE) }; + let Ok(page_size) = u64::try_from(page_raw) else { + return Vec::new(); + }; + if page_size == 0 { + return Vec::new(); + } + let self_pid = std::process::id(); + let Ok(entries) = std::fs::read_dir("/proc") else { + return Vec::new(); + }; + let mut samples = Vec::new(); + for entry in entries.flatten() { + let file_name = entry.file_name(); + let Some(pid_str) = file_name.to_str() else { + continue; + }; + let Ok(pid) = pid_str.parse::() else { + continue; + }; + if pid == self_pid { + continue; + } + let proc_dir = entry.path(); + let Ok(meta) = std::fs::metadata(&proc_dir) else { + continue; + }; + if meta.uid() != current_uid { + continue; + } + let Ok(statm) = std::fs::read_to_string(proc_dir.join("statm")) else { + continue; + }; + let Some(resident_pages) = statm + .split_whitespace() + .nth(1) + .and_then(|s| s.parse::().ok()) + else { + continue; + }; + let rss_bytes = resident_pages.saturating_mul(page_size); + if rss_bytes == 0 { + continue; + } + let Ok(comm) = std::fs::read_to_string(proc_dir.join("comm")) else { + continue; + }; + let name = comm.trim().to_owned(); + if name.is_empty() { + continue; + } + samples.push(ProcessMemorySample { + pid, + name, + rss_bytes, + }); + } + samples +} + +#[cfg(target_os = "macos")] +fn collect_user_process_memory() -> Vec { + const PROC_ALL_PIDS: libc::c_uint = 1; + const MAX_PIDS: usize = 4096; + + unsafe extern "C" { + fn proc_listpids( + kind: libc::c_uint, + typeinfo: u32, + buffer: *mut libc::c_void, + buffersize: libc::c_int, + ) -> libc::c_int; + fn proc_pid_rusage( + pid: libc::c_int, + flavor: libc::c_int, + buffer: *mut libc::rusage_info_v2, + ) -> libc::c_int; + fn proc_name( + pid: libc::c_int, + buffer: *mut libc::c_void, + buffersize: u32, + ) -> libc::c_int; + } + + let mut pids = vec![0_i32; MAX_PIDS]; + let byte_len = pids.len().saturating_mul(std::mem::size_of::()); + let Ok(buf_size) = libc::c_int::try_from(byte_len) else { + return Vec::new(); + }; + // SAFETY: `pids` is an allocated buffer of `buf_size` bytes that outlives the call. + // `proc_listpids` writes at most `buf_size` bytes and returns bytes written or <= 0 on error. + let bytes_written = unsafe { + proc_listpids( + PROC_ALL_PIDS, + 0, + pids.as_mut_ptr().cast::(), + buf_size, + ) + }; + if bytes_written <= 0 { + return Vec::new(); + } + let count = usize::try_from(bytes_written) + .unwrap_or(0) + .checked_div(std::mem::size_of::()) + .unwrap_or(0) + .min(pids.len()); + + let self_pid = std::process::id(); + let mut samples = Vec::new(); + for &raw_pid in pids.iter().take(count) { + if raw_pid <= 0 { + continue; + } + let Ok(pid) = u32::try_from(raw_pid) else { + continue; + }; + if pid == self_pid { + continue; + } + // SAFETY: `rusage_info_v2` is a plain-old-data C struct of integer counters; + // zero initialization is valid and `proc_pid_rusage` only populates it for processes + // accessible to the current user (returning non-zero for root/other-user processes). + let mut usage: libc::rusage_info_v2 = unsafe { std::mem::zeroed() }; + // SAFETY: `usage` is a valid `rusage_info_v2` matching `RUSAGE_INFO_V2`. + let rc = unsafe { proc_pid_rusage(raw_pid, libc::RUSAGE_INFO_V2, &raw mut usage) }; + if rc != 0 || usage.ri_phys_footprint == 0 { + continue; + } + let mut name_buf = [0_u8; 256]; + let Ok(name_cap) = u32::try_from(name_buf.len()) else { + continue; + }; + // SAFETY: `name_buf` is a valid mutable byte buffer of length `name_cap`. + let name_len = unsafe { + proc_name( + raw_pid, + name_buf.as_mut_ptr().cast::(), + name_cap, + ) + }; + if name_len <= 0 { + continue; + } + let valid_len = usize::try_from(name_len).unwrap_or(0).min(name_buf.len()); + let name = String::from_utf8_lossy(name_buf.get(..valid_len).unwrap_or(&[])) + .trim_end_matches('\0') + .trim() + .to_owned(); + if name.is_empty() { + continue; + } + samples.push(ProcessMemorySample { + pid, + name, + rss_bytes: usage.ri_phys_footprint, + }); + } + samples +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn collect_user_process_memory() -> Vec { + Vec::new() +} + +/// Ask the C/system allocator to return unused free heap pages back to the OS kernel. +/// Safe to invoke after dropping large scan results or when memory pressure is high. +pub fn reclaim_process_memory() -> bool { + reclaim_process_memory_impl() +} + +#[cfg(all(target_os = "linux", target_env = "gnu"))] +fn reclaim_process_memory_impl() -> bool { + unsafe extern "C" { + fn malloc_trim(pad: libc::size_t) -> libc::c_int; + } + // SAFETY: `malloc_trim(0)` is thread-safe in glibc and releases unused top-of-heap + // and arena pages back to the kernel without invalidating any live allocations. + unsafe { malloc_trim(0) != 0 } +} + +#[cfg(target_os = "macos")] +fn reclaim_process_memory_impl() -> bool { + unsafe extern "C" { + fn malloc_zone_pressure_relief( + zone: *mut libc::c_void, + goal: libc::size_t, + ) -> libc::size_t; + } + // SAFETY: Passing a null zone pointer instructs libsystem_malloc to relieve unused + // cached pages across all malloc zones; `goal = 0` requests maximal safe relief. + unsafe { malloc_zone_pressure_relief(std::ptr::null_mut(), 0) > 0 } +} + +#[cfg(not(any( + all(target_os = "linux", target_env = "gnu"), + target_os = "macos" +)))] +fn reclaim_process_memory_impl() -> bool { + false +} diff --git a/crates/cleanton-scanner/src/lib.rs b/crates/cleanton-scanner/src/lib.rs index 9c37db6..ca91a74 100644 --- a/crates/cleanton-scanner/src/lib.rs +++ b/crates/cleanton-scanner/src/lib.rs @@ -6,7 +6,8 @@ mod snapshots; mod walker; pub use capacity_ffi::{ - MemoryUsage, VolumeCapacity, is_case_sensitive, memory_usage, volume_capacity, + MemoryUsage, ProcessMemorySample, VolumeCapacity, is_case_sensitive, memory_usage, + reclaim_process_memory, top_memory_processes, volume_capacity, }; pub use cleanton_common::{Ecosystem, ProjectRoot}; pub use project::{detect_project_roots, detect_project_roots_from_entries}; diff --git a/crates/cleanton-scanner/src/project.rs b/crates/cleanton-scanner/src/project.rs index 3536630..ff80fcc 100644 --- a/crates/cleanton-scanner/src/project.rs +++ b/crates/cleanton-scanner/src/project.rs @@ -1,53 +1,84 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use cleanton_common::{Ecosystem, FileEntry, FileKind, ProjectRoot, paths::is_same_or_descendant}; +/// Dependency, cache, and build-output directory components whose internal +/// manifest files (`node_modules/lodash/package.json`, `.cargo/registry/src/.../Cargo.toml`, +/// `.venv/lib/.../pyproject.toml`) are vendored dependencies rather than user projects. +const DEPENDENCY_SUBTREE_COMPONENTS: &[&str] = &[ + "node_modules", + ".venv", + "venv", + "site-packages", + ".tox", + ".nox", + ".cargo", + ".rustup", + ".gradle", + ".m2", + ".pub-cache", + ".nuget", + ".cabal", + ".stack", + ".stack-work", + "dist-newstyle", + "_build", + "Pods", + "DerivedData", + ".cache", + "Caches", +]; + #[must_use] pub fn detect_project_roots(paths: impl IntoIterator) -> Vec { + let mut seen = HashSet::new(); paths .into_iter() + .filter(|path| !is_inside_dependency_subtree(path)) .filter_map(|path| marker_for(&path).map(|ecosystem| (path, ecosystem))) - .map(|(marker, ecosystem)| { + .filter_map(|(marker, ecosystem)| { let root = marker .parent() .unwrap_or_else(|| Path::new("/")) .to_path_buf(); + if !seen.insert((root.clone(), ecosystem)) { + return None; + } let artifact_dirs = artifact_dirs(ecosystem, &root); - ProjectRoot { + Some(ProjectRoot { path: root, ecosystem, artifact_dirs, total_size: 0, artifact_size: 0, - } + }) }) .collect() } #[must_use] pub fn detect_project_roots_from_entries(entries: &[FileEntry]) -> Vec { - // Index only DIRECTORY entries, and borrow their paths instead of cloning - // them. The previous index cloned the path of *every* scanned entry into a - // `HashMap` — millions of `PathBuf` allocations and an extra - // copy of the whole scan's path data on the analysis hot path — while every - // lookup below is a directory lookup. Directory entries are a small fraction - // of a scan, and none of them need a clone. let dirs_by_path = entries .iter() .filter(|entry| entry.kind == FileKind::Directory) .map(|entry| (entry.path.as_path(), entry)) .collect::>(); + let mut seen = HashSet::new(); entries .iter() + .filter(|entry| !is_inside_dependency_subtree(&entry.path)) .filter_map(|entry| marker_for(&entry.path).map(|ecosystem| (&entry.path, ecosystem))) - .map(|(marker, ecosystem)| { + .filter_map(|(marker, ecosystem)| { let root = marker .parent() .unwrap_or_else(|| Path::new("/")) .to_path_buf(); + if !seen.insert((root.clone(), ecosystem)) { + return None; + } let artifact_dirs = artifact_dirs(ecosystem, &root) .into_iter() .filter(|artifact| { @@ -62,17 +93,29 @@ pub fn detect_project_roots_from_entries(entries: &[FileEntry]) -> Vec bool { + let Some(parent) = marker_path.parent() else { + return false; + }; + parent.components().any(|component| { + component + .as_os_str() + .to_str() + .is_some_and(|name| DEPENDENCY_SUBTREE_COMPONENTS.contains(&name)) + }) +} + fn scan_entry_size( path: &Path, entries: &[FileEntry], @@ -80,10 +123,6 @@ fn scan_entry_size( ) -> u64 { dirs_by_path.get(path).map_or_else( || { - // The directory itself was not part of the scan (pruned, or beyond - // `max_depth`): fall back to the descendant files we did see. This - // is the rare path — in a real scan the directory is nearly always - // present — so the linear scan stays off the hot path. entries .iter() .filter(|entry| { @@ -102,13 +141,35 @@ fn marker_for(path: &Path) -> Option { "Cargo.toml" => Some(Ecosystem::Rust), "Package.swift" => Some(Ecosystem::Swift), "package.json" => Some(Ecosystem::Node), - "pyproject.toml" | "setup.py" => Some(Ecosystem::Python), + "pyproject.toml" | "setup.py" | "setup.cfg" => Some(Ecosystem::Python), "go.mod" => Some(Ecosystem::Go), - "Dockerfile" | "docker-compose.yml" => Some(Ecosystem::Docker), + "pom.xml" + | "build.gradle" + | "build.gradle.kts" + | "build.sbt" + | "deps.edn" + | "project.clj" => Some(Ecosystem::Jvm), + "CMakeLists.txt" | "meson.build" => Some(Ecosystem::CCpp), + "pubspec.yaml" => Some(Ecosystem::Dart), + "mix.exs" => Some(Ecosystem::Elixir), + "stack.yaml" | "cabal.project" => Some(Ecosystem::Haskell), + "build.zig" => Some(Ecosystem::Zig), + "composer.json" => Some(Ecosystem::Php), + "Gemfile" => Some(Ecosystem::Ruby), + "Dockerfile" | "docker-compose.yml" | "compose.yml" | "compose.yaml" => { + Some(Ecosystem::Docker) + } ".git" => Some(Ecosystem::Git), _ if name.ends_with(".xcodeproj") || name.ends_with(".xcworkspace") => { Some(Ecosystem::Xcode) } + _ if name.ends_with(".csproj") + || name.ends_with(".fsproj") + || name.ends_with(".sln") + || name.ends_with(".slnx") => + { + Some(Ecosystem::Dotnet) + } _ => None, } } @@ -119,15 +180,46 @@ fn artifact_dirs(ecosystem: Ecosystem, root: &Path) -> Vec { Ecosystem::Node => vec![ root.join("node_modules"), root.join(".next"), + root.join(".nuxt"), + root.join(".svelte-kit"), + root.join(".astro"), + root.join(".angular"), + root.join(".turbo"), + root.join(".parcel-cache"), root.join("dist"), ], Ecosystem::Python => vec![ root.join("__pycache__"), root.join(".venv"), + root.join("venv"), root.join(".tox"), + root.join(".nox"), + root.join(".pytest_cache"), + root.join(".mypy_cache"), + root.join(".ruff_cache"), ], - Ecosystem::Swift | Ecosystem::Xcode => vec![root.join(".build")], + Ecosystem::Swift | Ecosystem::Xcode => vec![root.join(".build"), root.join("DerivedData")], Ecosystem::Go => vec![root.join("bin")], + Ecosystem::Jvm => vec![ + root.join("target"), + root.join("build"), + root.join(".gradle"), + root.join(".bloop"), + root.join(".cpcache"), + ], + Ecosystem::CCpp => vec![ + root.join("build"), + root.join("cmake-build-debug"), + root.join("cmake-build-release"), + root.join("_build"), + ], + Ecosystem::Dotnet => vec![root.join("bin"), root.join("obj")], + Ecosystem::Dart => vec![root.join("build"), root.join(".dart_tool")], + Ecosystem::Elixir => vec![root.join("_build"), root.join("deps")], + Ecosystem::Haskell => vec![root.join(".stack-work"), root.join("dist-newstyle")], + Ecosystem::Zig => vec![root.join(".zig-cache"), root.join("zig-out")], + Ecosystem::Php => vec![root.join("vendor"), root.join(".phpunit.cache")], + Ecosystem::Ruby => vec![root.join("vendor")], Ecosystem::Docker | Ecosystem::Git => Vec::new(), } } @@ -200,6 +292,26 @@ mod tests { assert_eq!(proj.artifact_size, 0); } + #[test] + fn skips_dependency_subtrees_and_detects_additional_ecosystems() { + let paths = vec![ + PathBuf::from("/workspace/web/package.json"), + PathBuf::from("/workspace/web/node_modules/lodash/package.json"), + PathBuf::from("/Users/dev/.cargo/registry/src/index/serde-1.0/Cargo.toml"), + PathBuf::from("/workspace/api/App.csproj"), + PathBuf::from("/workspace/java/pom.xml"), + PathBuf::from("/workspace/cpp/CMakeLists.txt"), + PathBuf::from("/workspace/mobile/pubspec.yaml"), + ]; + let detected = detect_project_roots(paths); + assert_eq!(detected.len(), 5); + assert!(detected.iter().any(|p| p.ecosystem == Ecosystem::Node && p.path == Path::new("/workspace/web"))); + assert!(detected.iter().any(|p| p.ecosystem == Ecosystem::Dotnet && p.path == Path::new("/workspace/api"))); + assert!(detected.iter().any(|p| p.ecosystem == Ecosystem::Jvm && p.path == Path::new("/workspace/java"))); + assert!(detected.iter().any(|p| p.ecosystem == Ecosystem::CCpp && p.path == Path::new("/workspace/cpp"))); + assert!(detected.iter().any(|p| p.ecosystem == Ecosystem::Dart && p.path == Path::new("/workspace/mobile"))); + } + fn entry(path: PathBuf, kind: FileKind, logical_bytes: u64) -> FileEntry { FileEntry { path, diff --git a/crates/cleanton-scanner/src/walker.rs b/crates/cleanton-scanner/src/walker.rs index 244e984..12fd9e0 100644 --- a/crates/cleanton-scanner/src/walker.rs +++ b/crates/cleanton-scanner/src/walker.rs @@ -7,7 +7,7 @@ use std::{ use cleanton_common::{ CleantonError, FileEntry, FileKind, ProtectedPaths, Result, ScanError, ScanResult, Severity, - paths::{expand_tilde, is_same_or_descendant}, + paths::expand_tilde, }; use glob::Pattern; use serde::{Deserialize, Serialize}; @@ -188,15 +188,85 @@ fn child_ignored( parent_ignored } +#[derive(Clone)] +struct CompiledExclude { + pattern: Pattern, + basename_only: bool, +} + struct ReadDirContext { root: PathBuf, - options: ScanOptions, protected: ProtectedPaths, - excluded_roots: Vec, - excludes: Vec, + protected_scan_root: bool, + user_data_roots: ProtectedPaths, + active_excluded_roots: ProtectedPaths, + active_external_roots: ProtectedPaths, + excludes: Vec, respect_gitignore: bool, } +impl ReadDirContext { + fn new( + root: PathBuf, + options: &ScanOptions, + protected: ProtectedPaths, + excluded_roots: &[PathBuf], + excludes: &[CompiledExclude], + ) -> Self { + let protected_scan_root = protected.is_protected_fast(&root); + let user_data_roots = ProtectedPaths::configured_only(home_user_data_roots()); + + let active_excluded = excluded_roots + .iter() + .filter(|excluded| { + !ProtectedPaths::configured_only([(*excluded).clone()]).is_protected_fast(&root) + }) + .cloned() + .collect::>(); + let active_excluded_roots = ProtectedPaths::configured_only(active_excluded); + + let active_external = if options.exclude_external_volumes { + external_volume_roots() + .into_iter() + .filter(|ext| { + !ProtectedPaths::configured_only([ext.clone()]).is_protected_fast(&root) + }) + .collect::>() + } else { + Vec::new() + }; + let active_external_roots = ProtectedPaths::configured_only(active_external); + + Self { + root, + protected, + protected_scan_root, + user_data_roots, + active_excluded_roots, + active_external_roots, + excludes: excludes.to_vec(), + respect_gitignore: options.respect_gitignore, + } + } + + #[inline] + fn should_prune_dir(&self, path: &Path) -> bool { + if path == self.root { + return false; + } + if !self.protected_scan_root + && self.protected.is_protected_fast(path) + && !self.user_data_roots.is_protected_fast(path) + { + return true; + } + if self.active_excluded_roots.is_protected_fast(path) { + return true; + } + self.active_external_roots.is_protected_fast(path) + } +} + fn scan_blocking( options: &ScanOptions, progress_tx: &mpsc::Sender, @@ -232,7 +302,7 @@ fn scan_blocking( fn scan_root( root: &Path, options: &ScanOptions, - excludes: &[Pattern], + excludes: &[CompiledExclude], progress_tx: &mpsc::Sender, result: &mut ScanResult, cancel: Option<&std::sync::atomic::AtomicBool>, @@ -251,14 +321,8 @@ fn scan_root( let protected = ProtectedPaths::new(options.protected_paths.clone()); let excluded_roots = excluded_roots(options); let respect_gitignore = options.respect_gitignore; - let read_dir_context = ReadDirContext { - root: root.clone(), - options: options.clone(), - protected: protected.clone(), - excluded_roots: excluded_roots.clone(), - excludes: excludes.to_vec(), - respect_gitignore, - }; + let read_dir_context = + ReadDirContext::new(root.clone(), options, protected, &excluded_roots, excludes); let walker = jwalk::WalkDirGeneric::::new(&root) .max_depth(options.max_depth) @@ -312,57 +376,72 @@ fn process_scan_dir( children: &mut Vec, context: &ReadDirContext, ) { - if context.respect_gitignore - && depth.unwrap_or(0) > 0 - && let Some(gi) = load_gitignore(path) - { - read_dir_state.push(gi); - } - - // Resolve the directory's own ignored state once (the O(depth) parent walk), - // then reuse it for every child via the cheap single-level check. Without - // this, every entry re-walked all of its ancestors through the regex engine. - let parent_ignored = context.respect_gitignore && dir_ignored(read_dir_state, path); - - for child in children.iter_mut().flatten() { - let child_path = child.path(); - let is_dir = child.file_type.is_dir(); - child.client_state.ignored = context.respect_gitignore - && child_ignored(read_dir_state, parent_ignored, &child_path, is_dir); - } - // Never descend into `.git` internals: object stores are huge, no recipe // may ever target them (deleting inside a repository would corrupt it), // and project detection only needs the `.git` entry itself — which the // parent directory's pass has already yielded by the time we get here. - // Clearing this directory's children cuts the single biggest wasted - // subtree of most home scans. if path.file_name().and_then(|name| name.to_str()) == Some(".git") { children.clear(); return; } - children.retain(|child_result| match child_result { - Ok(child) => { - let child_path = child.path(); - // Pruning (protected roots + cloud/volume/excluded roots) only matters - // for directories — descending is what we avoid. File leaves are just - // recorded, and the matcher/executor re-check protection before any - // cleanup. Restricting the expensive path normalization to directories - // (a small fraction of entries) keeps large scans fast. - let prune = child.file_type.is_dir() - && should_skip_path( - &child_path, - &context.root, - &context.options, - &context.protected, - &context.excluded_roots, - ); - let skip = prune || matches_exclude_pattern(&child_path, &context.excludes); - !skip + // Check already-listed children for `.gitignore` in memory before touching + // disk, eliminating hundreds of thousands of `stat(".gitignore")` syscalls. + let has_local_gitignore = context.respect_gitignore + && depth.unwrap_or(0) > 0 + && children + .iter() + .flatten() + .any(|child| child.file_name == ".gitignore"); + if has_local_gitignore && let Some(gi) = load_gitignore(path) { + read_dir_state.push(gi); + } + + // Prune excluded/protected subtrees FIRST so we never allocate paths, + // evaluate gitignore regexes, or stat children we are about to drop. + let has_path_excludes = context.excludes.iter().any(|item| !item.basename_only); + if !context.excludes.is_empty() || children.iter().flatten().any(|c| c.file_type.is_dir()) { + children.retain(|child_result| match child_result { + Ok(child) => { + if matches_basename_exclude(&child.file_name, &context.excludes) { + return false; + } + let is_dir = child.file_type.is_dir(); + if !is_dir && !has_path_excludes { + return true; + } + let child_path = child.path(); + if is_dir && context.should_prune_dir(&child_path) { + return false; + } + !has_path_excludes || !matches_path_exclude(&child_path, &context.excludes) + } + Err(_) => true, + }); + } + + // Resolve the directory's own ignored state once, then short-circuit + // per-child regex matching whenever either: + // - the stack is empty (nothing is ignored), or + // - the parent directory is already ignored and no `.gitignore` on the + // stack contains whitelist (`!pattern`) rules (e.g. inside `node_modules` + // or `target`, every descendant is guaranteed ignored without regex runs). + if context.respect_gitignore && !read_dir_state.is_empty() { + let parent_ignored = dir_ignored(read_dir_state, path); + let any_whitelists = read_dir_state.iter().any(|gi| gi.num_whitelists() > 0); + if parent_ignored && !any_whitelists { + for child in children.iter_mut().flatten() { + child.client_state.ignored = true; + } + } else { + for child in children.iter_mut().flatten() { + let child_path = child.path(); + let is_dir = child.file_type.is_dir(); + child.client_state.ignored = + child_ignored(read_dir_state, parent_ignored, &child_path, is_dir); + } } - Err(_) => true, - }); + } // Read metadata here, on the worker thread, for the entries that survived // pruning — keeping every `stat(2)` off the single-threaded consumer loop. @@ -473,26 +552,40 @@ fn push_scan_error_message( }); } -fn compile_excludes(patterns: &[String]) -> Result> { +fn compile_excludes(patterns: &[String]) -> Result> { patterns .iter() .map(|pattern| { - Pattern::new(pattern).map_err(|err| { + let compiled = Pattern::new(pattern).map_err(|err| { CleantonError::Config(format!("invalid exclude pattern {pattern:?}: {err}")) + })?; + let basename_only = !pattern.contains('/') && !pattern.contains('\\'); + Ok(CompiledExclude { + pattern: compiled, + basename_only, }) }) .collect() } -fn matches_exclude_pattern(path: &Path, patterns: &[Pattern]) -> bool { - patterns.iter().any(|pattern| { - pattern.matches_path(path) - || path - .file_name() - .is_some_and(|name| pattern.matches(&name.to_string_lossy())) - }) +fn matches_basename_exclude(name: &std::ffi::OsStr, excludes: &[CompiledExclude]) -> bool { + if excludes.is_empty() { + return false; + } + let lossy = name.to_string_lossy(); + excludes + .iter() + .any(|item| item.pattern.matches(lossy.as_ref())) +} + +fn matches_path_exclude(path: &Path, excludes: &[CompiledExclude]) -> bool { + excludes + .iter() + .filter(|item| !item.basename_only) + .any(|item| item.pattern.matches_path(path)) } +#[cfg_attr(not(test), allow(dead_code))] fn should_skip_path( path: &Path, scan_root: &Path, @@ -504,15 +597,6 @@ fn should_skip_path( return false; } - // Bulk hot path: `is_protected_fast` does no filesystem syscalls (entries are - // already real walked paths), so pruning stays cheap across huge trees. - // - // User-data content roots (~/Desktop, ~/Documents, ~/Downloads, ~/Pictures, - // ~/Music, ~/Movies) are protected but are the one exception we still - // *descend* into: the read-only Suggestions pass (duplicates, large files, - // screenshots) needs their contents. Every destructive path (recipe matcher - // safety floor + executor) independently re-checks protection, so inventory - // here never widens what can be removed. if protected.is_protected_fast(path) && !protected.is_protected_fast(scan_root) && !cleanton_common::is_user_data_content_path(path) @@ -520,15 +604,54 @@ fn should_skip_path( return true; } - if excluded_roots.iter().any(|excluded| { - is_same_or_descendant(path, excluded) && !is_same_or_descendant(scan_root, excluded) - }) { - return true; + if !excluded_roots.is_empty() { + let excluded_set = ProtectedPaths::configured_only( + excluded_roots + .iter() + .filter(|excluded| { + !ProtectedPaths::configured_only([(*excluded).clone()]) + .is_protected_fast(scan_root) + }) + .cloned(), + ); + if excluded_set.is_protected_fast(path) { + return true; + } } - options.exclude_external_volumes - && is_same_or_descendant(path, Path::new("/Volumes")) - && !is_same_or_descendant(scan_root, Path::new("/Volumes")) + if options.exclude_external_volumes { + let ext_set = ProtectedPaths::configured_only( + external_volume_roots() + .into_iter() + .filter(|ext| { + !ProtectedPaths::configured_only([ext.clone()]).is_protected_fast(scan_root) + }), + ); + if ext_set.is_protected_fast(path) { + return true; + } + } + + false +} + +fn home_user_data_roots() -> Vec { + let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else { + return Vec::new(); + }; + cleanton_common::USER_DATA_CONTENT_ROOTS + .iter() + .map(|rel| home.join(rel)) + .collect() +} + +fn external_volume_roots() -> Vec { + vec![ + PathBuf::from("/Volumes"), + PathBuf::from("/media"), + PathBuf::from("/mnt"), + PathBuf::from("/run/media"), + ] } fn excluded_roots(options: &ScanOptions) -> Vec { @@ -554,16 +677,15 @@ fn excluded_roots(options: &ScanOptions) -> Vec { /// /// Both phases are O(entries): every file is folded into its DIRECT parent once, /// then the per-directory totals are propagated bottom-up (deepest directory -/// first) in a single ordered pass. The previous implementation folded every -/// file into *every* one of its ancestors, which is O(entries x depth) full-path -/// hash lookups per scan — tens of millions of them on a full home directory, -/// where depth is up to `max_depth` — and it also cloned every directory path -/// twice (once as a `HashSet` key, once as a `HashMap` key) purely to index them, -/// which is a large, avoidable allocation spike on multi-million-entry scans. +/// first) in a single ordered pass. fn aggregate_directory_sizes(result: &mut ScanResult) { - let mut inode_seen = HashSet::<(u64, u64)>::new(); + let approx_files = usize::try_from(result.total_files).unwrap_or(0); + let approx_dirs = usize::try_from(result.total_dirs).unwrap_or(0); + let mut inode_seen = HashSet::<(u64, u64)>::with_capacity(approx_files / 2); + let mut total_logical = 0_u64; + let mut total_allocated = 0_u64; - // 1. De-duplicate entry sizes for hardlinked inodes first + // 1. De-duplicate entry sizes for hardlinked inodes first and accumulate scan totals for entry in &mut result.entries { if entry.kind != FileKind::File { continue; @@ -574,21 +696,25 @@ fn aggregate_directory_sizes(result: &mut ScanResult) { entry.logical_bytes = 0; entry.allocated_bytes = 0; } + total_logical = total_logical.saturating_add(entry.logical_bytes); + total_allocated = total_allocated.saturating_add(entry.allocated_bytes); } // 2. Per-directory subtree totals. Borrowed path keys keep this allocation // free beyond the accumulator vectors themselves. let (dir_entry_indices, totals) = { let entries = &result.entries; - let mut slot_of: HashMap<&Path, usize> = HashMap::new(); - let mut dir_paths: Vec<&Path> = Vec::new(); - let mut dir_entry_indices: Vec = Vec::new(); + let mut slot_of: HashMap<&Path, usize> = HashMap::with_capacity(approx_dirs); + let mut dir_paths: Vec<&Path> = Vec::with_capacity(approx_dirs); + let mut dir_depths: Vec = Vec::with_capacity(approx_dirs); + let mut dir_entry_indices: Vec = Vec::with_capacity(approx_dirs); for (index, entry) in entries.iter().enumerate() { if entry.kind != FileKind::Directory || slot_of.contains_key(entry.path.as_path()) { continue; } slot_of.insert(entry.path.as_path(), dir_paths.len()); dir_paths.push(entry.path.as_path()); + dir_depths.push(entry.depth); dir_entry_indices.push(index); } @@ -611,15 +737,19 @@ fn aggregate_directory_sizes(result: &mut ScanResult) { } } - // Bottom-up: a directory's own subtree total lands in its parent before - // the parent is itself folded upward. + // Bottom-up: sort by recorded depth (falling back to component count if equal) + // so child directory subtree totals land in their parent before the parent folds up. let mut order: Vec = (0..dir_paths.len()).collect(); order.sort_unstable_by_key(|slot| { - std::cmp::Reverse( + let depth = dir_depths.get(*slot).copied().unwrap_or(0); + let comp = if depth == 0 { dir_paths .get(*slot) - .map_or(0, |path| path.components().count()), - ) + .map_or(0, |path| path.components().count()) + } else { + depth + }; + std::cmp::Reverse(comp) }); for slot in order { let Some(path) = dir_paths.get(slot) else { @@ -657,19 +787,9 @@ fn aggregate_directory_sizes(result: &mut ScanResult) { } } - // 4. Calculate total scan results sizes - result.logical_bytes = result - .entries - .iter() - .filter(|entry| entry.kind == FileKind::File) - .map(|entry| entry.logical_bytes) - .sum(); - result.allocated_bytes = result - .entries - .iter() - .filter(|entry| entry.kind == FileKind::File) - .map(|entry| entry.allocated_bytes) - .sum(); + // 4. Commit total scan results sizes + result.logical_bytes = total_logical; + result.allocated_bytes = total_allocated; } #[cfg(test)] diff --git a/crates/cleanton-services/src/launchd.rs b/crates/cleanton-services/src/launchd.rs index f2824d5..f28bf0a 100644 --- a/crates/cleanton-services/src/launchd.rs +++ b/crates/cleanton-services/src/launchd.rs @@ -31,14 +31,17 @@ pub struct ParsedPlist { #[must_use] pub fn get_current_uid() -> u32 { - let output = reviewed_process::output("id", ["-u"]); - if let Ok(out) = output { - let stdout = String::from_utf8_lossy(&out.stdout); - if let Ok(uid) = stdout.trim().parse::() { - return uid; + static UID: std::sync::OnceLock = std::sync::OnceLock::new(); + *UID.get_or_init(|| { + let output = reviewed_process::output("id", ["-u"]); + if let Ok(out) = output { + let stdout = String::from_utf8_lossy(&out.stdout); + if let Ok(uid) = stdout.trim().parse::() { + return uid; + } } - } - 501 + 501 + }) } pub fn parse_plist(path: &Path) -> Result { @@ -101,24 +104,61 @@ pub fn parse_plist(path: &Path) -> Result { }) } +/// Batch snapshot of `launchctl list`: maps each loaded service label to +/// `Some(pid)` when actively running or `None` when loaded-but-idle. #[must_use] -pub fn get_launchd_pids() -> HashMap { - let mut pids = HashMap::new(); +pub fn get_launchctl_snapshot() -> HashMap> { + let mut entries = HashMap::new(); let Ok(output) = reviewed_process::output("launchctl", ["list"]) else { - return pids; + return entries; }; let stdout = String::from_utf8_lossy(&output.stdout); for line in stdout.lines().skip(1) { let parts: Vec<&str> = line.split_whitespace().collect(); - if let [pid_str, _, label, ..] = parts.as_slice() - && *pid_str != "-" - && let Ok(pid) = pid_str.parse::() - { - pids.insert((*label).to_string(), pid); + if let [pid_str, _, label, ..] = parts.as_slice() { + let pid = if *pid_str == "-" { + None + } else { + pid_str.parse::().ok() + }; + entries.insert((*label).to_string(), pid); + } + } + entries +} + +#[must_use] +pub fn get_launchd_pids() -> HashMap { + get_launchctl_snapshot() + .into_iter() + .filter_map(|(label, pid)| pid.map(|p| (label, p))) + .collect() +} + +/// Resolve status using a pre-captured `launchctl list` snapshot, avoiding +/// spawning `launchctl list` + `launchctl print` once per plist file. +#[must_use] +pub fn get_launchd_status_from_snapshot( + label: &str, + _service_type: ServiceType, + snapshot: &HashMap, S1>, + proc_stats: &HashMap, +) -> ServiceStatus { + match snapshot.get(label) { + Some(Some(pid)) => { + let (cpu, mem) = proc_stats + .get(pid) + .map_or((None, None), |&(c, m)| (Some(c), Some(m))); + ServiceStatus::Running { + pid: *pid, + cpu_percent: cpu, + memory_bytes: mem, + } } + Some(None) => ServiceStatus::Loaded, + None => ServiceStatus::Stopped, } - pids } #[must_use] @@ -127,21 +167,13 @@ pub fn get_launchd_status( service_type: ServiceType, proc_stats: &HashMap, ) -> ServiceStatus { - let is_daemon = service_type == ServiceType::SystemLaunchDaemon; - - // Fast path: launchctl list - let pids = get_launchd_pids(); - if let Some(&pid) = pids.get(label) { - let (cpu, mem) = proc_stats - .get(&pid) - .map_or((None, None), |&(c, m)| (Some(c), Some(m))); - return ServiceStatus::Running { - pid, - cpu_percent: cpu, - memory_bytes: mem, - }; + let snapshot = get_launchctl_snapshot(); + if !snapshot.is_empty() { + return get_launchd_status_from_snapshot(label, service_type, &snapshot, proc_stats); } + let is_daemon = service_type == ServiceType::SystemLaunchDaemon; + // fallback / system daemons or other domains let uid = get_current_uid(); let domain = if is_daemon { diff --git a/crates/cleanton-services/src/lib.rs b/crates/cleanton-services/src/lib.rs index a62727b..09468d6 100644 --- a/crates/cleanton-services/src/lib.rs +++ b/crates/cleanton-services/src/lib.rs @@ -4,6 +4,7 @@ pub mod manager; mod reviewed_plist_delete; mod reviewed_process; pub mod safety; +pub mod systemd; pub mod types; pub use manager::{ServiceElevation, ServiceManager, ServiceMutationOptions}; diff --git a/crates/cleanton-services/src/manager.rs b/crates/cleanton-services/src/manager.rs index 04bb487..09f7be2 100644 --- a/crates/cleanton-services/src/manager.rs +++ b/crates/cleanton-services/src/manager.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use crate::types::{Service, ServiceStatus, ServiceType}; -use crate::{brew, launchd, reviewed_plist_delete, safety}; +use crate::{brew, launchd, reviewed_plist_delete, safety, systemd}; use cleanton_common::{CleantonConfig, CleantonError}; #[derive(Debug)] @@ -73,6 +73,7 @@ impl ServiceManager { pub async fn list_services(&self) -> Result, CleantonError> { let mut services = Vec::new(); let proc_stats = launchd::get_all_processes_stats(); + let launchctl_snapshot = launchd::get_launchctl_snapshot(); // 1. Get Brew services first let brew_list = brew::list_brew_services().unwrap_or_default(); @@ -149,8 +150,13 @@ impl ServiceManager { parsed.label.clone() }; - // Get status - let status = launchd::get_launchd_status(&parsed.label, final_type, &proc_stats); + // Get status from pre-fetched launchctl snapshot (O(1), zero per-plist subprocesses) + let status = launchd::get_launchd_status_from_snapshot( + &parsed.label, + final_type, + &launchctl_snapshot, + &proc_stats, + ); services.push(Service { id: parsed.label.clone(), @@ -209,7 +215,12 @@ impl ServiceManager { None }; - let status = launchd::get_launchd_status(&label, ServiceType::BrewService, &proc_stats); + let status = launchd::get_launchd_status_from_snapshot( + &label, + ServiceType::BrewService, + &launchctl_snapshot, + &proc_stats, + ); services.push(Service { id: label.clone(), @@ -225,6 +236,9 @@ impl ServiceManager { }); } + // 3. Discover systemd user services on Linux (~/.config/systemd/user/*.service) + services.extend(systemd::list_user_services(&proc_stats)); + services.sort_by(|a, b| a.id.cmp(&b.id)); Ok(services) } @@ -362,6 +376,32 @@ impl ServiceManager { let requires_elevation = should_run_as_root(service); let use_elevation = requires_elevation && options.requests_elevation(); + if service.service_type == ServiceType::SystemdUserService { + let verb = match action { + ServiceAction::Start => "start", + ServiceAction::Stop => "stop", + ServiceAction::Restart => "restart", + ServiceAction::Enable => { + systemd::run_systemctl_user_action( + &service.id, + "enable", + &service.plist_path, + )?; + "start" + } + ServiceAction::Disable => { + systemd::run_systemctl_user_action( + &service.id, + "disable", + &service.plist_path, + )?; + "stop" + } + }; + systemd::run_systemctl_user_action(&service.id, verb, &service.plist_path)?; + return Ok(()); + } + if service.service_type == ServiceType::BrewService { if use_elevation { return Err(CleantonError::ExecutionRefused(format!( @@ -441,7 +481,13 @@ impl ServiceManager { backup_service_plist(&service.plist_path)?; - if use_elevation { + if service.service_type == ServiceType::SystemdUserService { + let _ = + systemd::run_systemctl_user_action(&service.id, "disable", &service.plist_path); + let _ = systemd::run_systemctl_user_action(&service.id, "stop", &service.plist_path); + reviewed_plist_delete::remove_service_plist(&service.plist_path) + .map_err(|e| CleantonError::io(&service.plist_path, e))?; + } else if use_elevation { let _ = launchd::bootout_service_elevated(&service.plist_path, service.service_type); reviewed_plist_delete::remove_service_plist_elevated(&service.plist_path) .map_err(|e| CleantonError::io(&service.plist_path, e))?; @@ -529,7 +575,12 @@ impl ServiceManager { backup_service_plist(&service.plist_path)?; - if use_elevation { + if service.service_type == ServiceType::SystemdUserService { + let _ = + systemd::run_systemctl_user_action(&service.id, "disable", &service.plist_path); + reviewed_plist_delete::remove_service_plist(&service.plist_path) + .map_err(|e| CleantonError::io(&service.plist_path, e))?; + } else if use_elevation { let _ = launchd::bootout_service_elevated(&service.plist_path, service.service_type); reviewed_plist_delete::remove_service_plist_elevated(&service.plist_path) .map_err(|e| CleantonError::io(&service.plist_path, e))?; diff --git a/crates/cleanton-services/src/safety.rs b/crates/cleanton-services/src/safety.rs index bc8c7ac..6d00014 100644 --- a/crates/cleanton-services/src/safety.rs +++ b/crates/cleanton-services/src/safety.rs @@ -8,11 +8,31 @@ pub enum PrivilegedMutationReason { RootOwnedPlist, } +const PROTECTED_LINUX_PREFIXES: &[&str] = &[ + "dbus", + "systemd-", + "pipewire", + "wireplumber", + "pulseaudio", + "gnome-", + "xdg-", + "gvfs-", + "at-spi", + "ssh-agent", + "gpg-agent", +]; + #[must_use] pub fn is_protected(label: &str, plist_path: &Path) -> bool { + let lower = label.to_ascii_lowercase(); label.starts_with("com.apple.") + || PROTECTED_LINUX_PREFIXES + .iter() + .any(|prefix| lower.starts_with(prefix)) || plist_path.starts_with("/System/Library") || plist_path.starts_with("/Library/Apple") + || plist_path.starts_with("/usr/lib/systemd") + || plist_path.starts_with("/lib/systemd") } pub fn check_mutation_safety(label: &str, plist_path: &Path) -> Result<()> { diff --git a/crates/cleanton-services/src/systemd.rs b/crates/cleanton-services/src/systemd.rs new file mode 100644 index 0000000..fa5bba9 --- /dev/null +++ b/crates/cleanton-services/src/systemd.rs @@ -0,0 +1,285 @@ +use std::collections::HashMap; +use std::hash::BuildHasher; +use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; + +use cleanton_common::{CleantonError, Result}; + +use crate::reviewed_process; +use crate::types::{Service, ServiceStatus, ServiceType}; + +#[derive(Debug, Default)] +struct ParsedUnit { + description: Option, + program: Option, + program_arguments: Vec, + wanted_by: Vec, +} + +/// Discover user-owned systemd `.service` units under `~/.config/systemd/user` +/// and resolve their live state in a single `systemctl --user show` batch call. +#[must_use] +pub fn list_user_services( + proc_stats: &HashMap, +) -> Vec { + let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else { + return Vec::new(); + }; + let user_dir = home.join(".config/systemd/user"); + list_user_services_in_dir(&user_dir, proc_stats) +} + +#[must_use] +pub fn list_user_services_in_dir( + user_dir: &Path, + proc_stats: &HashMap, +) -> Vec { + let Ok(entries) = std::fs::read_dir(user_dir) else { + return Vec::new(); + }; + + let mut units: Vec<(String, PathBuf, ParsedUnit, Option, bool)> = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("service") || !path.is_file() { + continue; + } + let Some(unit_id) = path + .file_name() + .and_then(|n| n.to_str()) + .map(str::to_owned) + else { + continue; + }; + let Ok(contents) = std::fs::read_to_string(&path) else { + continue; + }; + let parsed = parse_unit_file(&contents); + let owner_uid = std::fs::metadata(&path).ok().map(|m| m.uid()); + let enabled = is_unit_enabled_on_disk(user_dir, &unit_id, &parsed.wanted_by); + units.push((unit_id, path, parsed, owner_uid, enabled)); + } + + if units.is_empty() { + return Vec::new(); + } + + let unit_ids: Vec<&str> = units.iter().map(|(id, ..)| id.as_str()).collect(); + let live_states = query_systemctl_states(&unit_ids); + + units + .into_iter() + .map(|(unit_id, path, parsed, user_id, enabled)| { + let status = live_states.get(&unit_id).map_or(ServiceStatus::Stopped, |st| { + match (st.active_state.as_str(), st.main_pid) { + ("active" | "activating" | "reloading", Some(pid)) if pid > 0 => { + let (cpu, mem) = proc_stats + .get(&pid) + .map_or((None, None), |&(c, m)| (Some(c), Some(m))); + ServiceStatus::Running { + pid, + cpu_percent: cpu, + memory_bytes: mem, + } + } + ("active", None) => ServiceStatus::Loaded, + ("failed", _) => ServiceStatus::Error("failed".to_owned()), + _ => ServiceStatus::Stopped, + } + }); + let short_name = parsed.description.unwrap_or_else(|| { + unit_id + .strip_suffix(".service") + .unwrap_or(&unit_id) + .to_owned() + }); + Service { + id: unit_id.clone(), + name: short_name, + service_type: ServiceType::SystemdUserService, + status, + plist_path: path, + program: parsed.program, + program_arguments: parsed.program_arguments, + run_at_load: enabled, + label: unit_id, + user_id, + } + }) + .collect() +} + +pub fn run_systemctl_user_action(unit_id: &str, verb: &str, unit_path: &Path) -> Result<()> { + let output = reviewed_process::output("systemctl", ["--user", verb, unit_id]) + .map_err(|err| CleantonError::io(unit_path, err))?; + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + Err(CleantonError::Execution { + path: unit_path.to_path_buf(), + message: if stderr.is_empty() { + format!("systemctl --user {verb} {unit_id} exited with {}", output.status) + } else { + format!("systemctl --user {verb} {unit_id} failed: {stderr}") + }, + }) + } +} + +#[derive(Debug, Default)] +struct UnitLiveState { + active_state: String, + main_pid: Option, +} + +fn query_systemctl_states(unit_ids: &[&str]) -> HashMap { + let mut states = HashMap::new(); + if unit_ids.is_empty() { + return states; + } + let mut args = vec![ + "--user", + "show", + "--property=Id,ActiveState,MainPID", + "--", + ]; + args.extend_from_slice(unit_ids); + let Ok(output) = reviewed_process::output("systemctl", args) else { + return states; + }; + let stdout = String::from_utf8_lossy(&output.stdout); + parse_systemctl_show_output(&stdout, &mut states); + states +} + +fn parse_systemctl_show_output(stdout: &str, out: &mut HashMap) { + let mut current_id = String::new(); + let mut current = UnitLiveState::default(); + for line in stdout.lines().chain(std::iter::once("")) { + let trimmed = line.trim(); + if trimmed.is_empty() { + if !current_id.is_empty() { + out.insert( + std::mem::take(&mut current_id), + std::mem::take(&mut current), + ); + } + continue; + } + if let Some(val) = trimmed.strip_prefix("Id=") { + current_id = val.to_owned(); + } else if let Some(val) = trimmed.strip_prefix("ActiveState=") { + current.active_state = val.to_owned(); + } else if let Some(val) = trimmed.strip_prefix("MainPID=") + && let Ok(pid) = val.parse::() + && pid > 0 + { + current.main_pid = Some(pid); + } + } +} + +fn is_unit_enabled_on_disk(user_dir: &Path, unit_id: &str, wanted_by: &[String]) -> bool { + for target in wanted_by + .iter() + .map(String::as_str) + .chain(["default.target", "graphical-session.target"]) + { + if user_dir + .join(format!("{target}.wants")) + .join(unit_id) + .symlink_metadata() + .is_ok() + { + return true; + } + } + false +} + +fn parse_unit_file(contents: &str) -> ParsedUnit { + let mut parsed = ParsedUnit::default(); + let mut section = ""; + for raw_line in contents.lines() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with(';') { + continue; + } + if line.starts_with('[') && line.ends_with(']') { + section = line; + continue; + } + let Some((key, raw_val)) = line.split_once('=') else { + continue; + }; + let key = key.trim(); + let val = raw_val.trim(); + match (section, key) { + ("[Unit]", "Description") if !val.is_empty() => { + parsed.description = Some(val.to_owned()); + } + ("[Service]", "ExecStart") if parsed.program.is_none() && !val.is_empty() => { + let cleaned = val.trim_start_matches(['-', '@', '+', '!', ':']); + let args: Vec = cleaned + .split_whitespace() + .map(|part| part.trim_matches(['"', '\'']).to_owned()) + .filter(|part| !part.is_empty()) + .collect(); + if let Some(first) = args.first() { + parsed.program = Some(PathBuf::from(first)); + } + parsed.program_arguments = args; + } + ("[Install]", "WantedBy") if !val.is_empty() => { + parsed + .wanted_by + .extend(val.split_whitespace().map(str::to_owned)); + } + _ => {} + } + } + parsed +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_systemd_unit_and_wants_enablement() { + let temp = tempfile::tempdir().expect("tempdir"); + let wants = temp.path().join("default.target.wants"); + std::fs::create_dir_all(&wants).expect("wants dir"); + let unit_path = temp.path().join("redis-local.service"); + std::fs::write( + &unit_path, + "[Unit]\nDescription=Local Redis Cache\n[Service]\nExecStart=/usr/bin/redis-server --port 6379\n[Install]\nWantedBy=default.target\n", + ) + .expect("write unit"); + #[cfg(unix)] + std::os::unix::fs::symlink(&unit_path, wants.join("redis-local.service")) + .expect("symlink wants"); + + let services = list_user_services_in_dir(temp.path(), &HashMap::new()); + assert_eq!(services.len(), 1); + let svc = services.first().expect("service"); + assert_eq!(svc.id, "redis-local.service"); + assert_eq!(svc.name, "Local Redis Cache"); + assert_eq!(svc.service_type, ServiceType::SystemdUserService); + assert_eq!(svc.program, Some(PathBuf::from("/usr/bin/redis-server"))); + assert!(svc.run_at_load); + } + + #[test] + fn parses_systemctl_show_blocks() { + let sample = "Id=foo.service\nActiveState=active\nMainPID=4242\n\nId=bar.service\nActiveState=inactive\nMainPID=0\n"; + let mut out = HashMap::new(); + parse_systemctl_show_output(sample, &mut out); + assert_eq!(out.get("foo.service").and_then(|s| s.main_pid), Some(4242)); + assert_eq!( + out.get("bar.service").map(|s| s.active_state.as_str()), + Some("inactive") + ); + } +} diff --git a/crates/cleanton-services/src/types.rs b/crates/cleanton-services/src/types.rs index ff5871a..9b2515b 100644 --- a/crates/cleanton-services/src/types.rs +++ b/crates/cleanton-services/src/types.rs @@ -34,6 +34,7 @@ pub enum ServiceType { SystemLaunchAgent, // /Library/LaunchAgents SystemLaunchDaemon, // /Library/LaunchDaemons BrewService, // Managed by brew services + SystemdUserService, // ~/.config/systemd/user/*.service } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/cleanton-ui/src/app.rs b/crates/cleanton-ui/src/app.rs index 00bbce6..5c235a2 100644 --- a/crates/cleanton-ui/src/app.rs +++ b/crates/cleanton-ui/src/app.rs @@ -120,8 +120,9 @@ pub struct App { pub file_tree_state: Option, pub pending_cleanup_confirmation: Option>, - // Disk capacity + real usage history (no mock data) + // Disk capacity + real usage history + background helper resource snapshot pub capacity_view: CapacityView, + pub resource_status: cleanton_engine::ResourceStatus, pub pending_empty_trash_confirmation: bool, // Services state @@ -265,6 +266,7 @@ impl App { file_tree_state: None, pending_cleanup_confirmation: None, capacity_view: CapacityView::default(), + resource_status: cleanton_engine::ResourceStatus::default(), pending_empty_trash_confirmation: false, services: Vec::new(), selected_service_idx: 0, @@ -324,9 +326,25 @@ impl App { pub fn refresh_capacity(&mut self) { let root = cleanton_common::paths::expand_tilde(&self.config.general.default_root); self.capacity_view = capacity::read(&root); + self.resource_status = cleanton_engine::snapshot_resource_status( + self.config.background.disk_pressure_percent, + self.config.background.memory_pressure_percent, + ); self.rebuild_snapshot_review(); } + /// Reclaims unused Cleanton allocator pages and refreshes live memory status. + pub fn reclaim_memory(&mut self) { + let _ = cleanton_scanner::reclaim_process_memory(); + self.resource_status = cleanton_engine::snapshot_resource_status( + self.config.background.disk_pressure_percent, + self.config.background.memory_pressure_percent, + ); + self.set_status( + "Memory cleanup: reclaimed unused allocator pages. Review idle background services below.", + ); + } + /// Derives the Results-screen snapshot review model from the freshly read /// capacity view. Every rebuild clears the selection and disarms any /// pending confirmation: a confirmation must never survive a change of the diff --git a/crates/cleanton-ui/src/lib.rs b/crates/cleanton-ui/src/lib.rs index a87a524..d05cf35 100644 --- a/crates/cleanton-ui/src/lib.rs +++ b/crates/cleanton-ui/src/lib.rs @@ -887,6 +887,12 @@ async fn handle_key_event( || key.code == crossterm::event::KeyCode::Char(' ') { trigger_scan(app, tui_tx); + } else if key.code == crossterm::event::KeyCode::Char('m') { + app.reclaim_memory(); + app.active_screen = ActiveScreen::Services; + if app.services.is_empty() && !app.is_loading_services { + load_services(app, service_tx); + } } } ActiveScreen::Packages => handle_packages_key(key, app, tui_tx), diff --git a/crates/cleanton-ui/src/screens/dashboard.rs b/crates/cleanton-ui/src/screens/dashboard.rs index cfe79b5..d2870a8 100644 --- a/crates/cleanton-ui/src/screens/dashboard.rs +++ b/crates/cleanton-ui/src/screens/dashboard.rs @@ -58,7 +58,7 @@ pub fn render(f: &mut Frame, app: &mut App, area: Rect) { .add_modifier(Modifier::BOLD), )), Line::from(""), - Line::from(" Welcome to your macOS Disk Cleaner."), + Line::from(" Welcome to your macOS & Linux Disk Cleaner."), Line::from(" No scan results available yet."), Line::from(""), Line::from(Span::styled( @@ -83,10 +83,10 @@ pub fn render(f: &mut Frame, app: &mut App, area: Rect) { let right_chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(9), // Statistics - Constraint::Length(9), // Disk capacity (honest) - Constraint::Length(6), // Real usage-history sparkline - Constraint::Min(3), // Control center / help + Constraint::Length(9), // Statistics + Constraint::Length(11), // Disk & memory capacity + cleanup offers + Constraint::Length(5), // Real usage-history sparkline + Constraint::Min(3), // Control center / help ]) .split(chunks[1]); @@ -184,13 +184,13 @@ pub fn render(f: &mut Frame, app: &mut App, area: Rect) { ); f.render_widget(stats_para, right_chunks[0]); - // Honest disk capacity (total / used / available approx / purgeable / snapshots) + // Honest disk & memory capacity + background helper pressure offers let capacity_para = Paragraph::new(get_capacity_lines(app)).block( Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(app.theme.border)) .title(Span::styled( - "Disk Capacity", + "Disk & Memory Status", Style::default().add_modifier(Modifier::BOLD), )), ); @@ -241,7 +241,14 @@ pub fn render(f: &mut Frame, app: &mut App, area: Rect) { .fg(app.theme.success) .add_modifier(Modifier::BOLD), ), - Span::styled("Trigger Filesystem Scan", Style::default().fg(app.theme.fg)), + Span::styled("Scan Disk ", Style::default().fg(app.theme.fg)), + Span::styled( + "[m] ", + Style::default() + .fg(app.theme.accent) + .add_modifier(Modifier::BOLD), + ), + Span::styled("Memory Cleanup", Style::default().fg(app.theme.fg)), ]), Line::from(vec![ Span::styled( @@ -369,6 +376,47 @@ fn get_capacity_lines(app: &App) -> Vec> { ]), ]; + if app.resource_status.memory_total_bytes > 0 { + let mem_used = app + .resource_status + .memory_total_bytes + .saturating_sub(app.resource_status.memory_available_bytes); + let mem_pct = app.resource_status.memory_used_percent; + let mem_color = if app.resource_status.memory_offer.is_some() { + app.theme.warning + } else { + app.theme.fg + }; + lines.push(Line::from(vec![ + label("Memory:"), + Span::styled( + format!( + "{} / {} ({mem_pct}%)", + human_bytes(mem_used), + human_bytes(app.resource_status.memory_total_bytes) + ), + Style::default().fg(mem_color), + ), + ])); + } + + if app.resource_status.disk_offer.is_some() { + lines.push(Line::from(Span::styled( + " Disk usage high: press [s] to scan caches & build artifacts", + Style::default() + .fg(app.theme.warning) + .add_modifier(Modifier::BOLD), + ))); + } + if app.resource_status.memory_offer.is_some() { + lines.push(Line::from(Span::styled( + " Memory usage high: press [m] to reclaim & inspect Services", + Style::default() + .fg(app.theme.warning) + .add_modifier(Modifier::BOLD), + ))); + } + // Trash vs Freed vs Reclaimable. if let Some(result) = &app.execution_result { if result.moved_to_trash_bytes > 0 { diff --git a/crates/cleanton-ui/src/screens/services.rs b/crates/cleanton-ui/src/screens/services.rs index d94e4dd..56d56ce 100644 --- a/crates/cleanton-ui/src/screens/services.rs +++ b/crates/cleanton-ui/src/screens/services.rs @@ -147,6 +147,7 @@ pub fn render(f: &mut Frame, app: &App, area: Rect) { ServiceType::SystemLaunchAgent => "System LaunchAgent (/Library/LaunchAgents)", ServiceType::SystemLaunchDaemon => "System LaunchDaemon (/Library/LaunchDaemons)", ServiceType::BrewService => "Homebrew Managed Service", + ServiceType::SystemdUserService => "systemd User Service (~/.config/systemd/user)", }; let detail_text = vec![ diff --git a/doc/RECIPES.md b/doc/RECIPES.md index 74a8538..6c19b3e 100644 --- a/doc/RECIPES.md +++ b/doc/RECIPES.md @@ -862,3 +862,31 @@ Every built-in recipe must satisfy these checks before it can ship: | **Min Age** | 30 days | | **Action** | Trash | | **Notes** | Downloaded pnpm/yarn shims are re-downloaded on demand. | + +### `dotnet-obj-dirs`, `dotnet-http-cache` +| Field | Value | +|---|---| +| **Safety** | `safe` | +| **Match** | `obj/` containing `project.assets.json`; NuGet HTTP/v3 feed caches | +| **Action** | Trash | + +### `jvm-maven-target-dirs`, `jvm-project-gradle-dirs`, `jvm-scala-build-dirs`, `clojure-build-caches` +| Field | Value | +|---|---| +| **Safety** | `safe` | +| **Match** | Maven `target/`, Gradle `.gradle/`, Scala `target`/`.bloop`/`.scala-build`, Clojure `.cpcache`/Leiningen `target` | +| **Action** | Trash | + +### `node-modern-framework-dirs`, `node-nx-cache-dirs` +| Field | Value | +|---|---| +| **Safety** | `safe` | +| **Match** | `.svelte-kit`, `.nuxt`, `.output`, `.astro`, `.angular`, `.docusaurus`, `.expo`, `.wrangler`, `.nx/cache`, `.nx/workspace-data` | +| **Action** | Trash | + +### `c-cpp-build-variants`, `c-cpp-vcpkg-cache`, `dart-tool-build-dirs`, `haskell-cabal-build-dirs`, `ocaml-dune-build-dirs`, `android-build-caches`, `godot-import-cache`, `php-tool-caches`, `ide-vscode-extension-vsixs`, `ide-editor-caches` +| Field | Value | +|---|---| +| **Safety** | `safe` / `cautious` | +| **Match** | CLion `cmake-build-*` & Meson build dirs, vcpkg archives, `.dart_tool`, Cabal `dist-newstyle`, Dune `_build`, Android `.cxx`, Godot `.godot`, PHPUnit/PHPStan/Psalm caches, VSIX installers, Neovim/JetBrains/Fleet/Eclipse/Sublime caches | +| **Action** | Trash | diff --git a/recipes/ai-cursor-cache.toml b/recipes/ai-cursor-cache.toml index 9fd7a74..6d191c5 100644 --- a/recipes/ai-cursor-cache.toml +++ b/recipes/ai-cursor-cache.toml @@ -16,10 +16,24 @@ priority = 50 path_glob = "~/Library/Application Support/Cursor/*Cache*/**" min_age_days = 7 +[[match.any_of]] +path_glob = "~/.config/Cursor/*Cache*/**" +min_age_days = 7 + [action] type = "trash" prompt = "Move {size} of Cursor cache to Trash?" [test] -should_match = ["~/Library/Application Support/Cursor/Cache/foo", "~/Library/Application Support/Cursor/Code Cache/js/index_0", "~/Library/Application Support/Cursor/GPUCache/data_1"] -should_not_match = ["~/Library/Application Support/Cursor/User", "~/Library/Application Support/Cursor/User/globalStorage/storage.json", "~/Library/Application Support/Cursor/Session Storage/000003.log"] +should_match = [ + "~/Library/Application Support/Cursor/Cache/foo", + "~/Library/Application Support/Cursor/Code Cache/js/index_0", + "~/Library/Application Support/Cursor/GPUCache/data_1", + "~/.config/Cursor/CachedData/abc/module.js", +] +should_not_match = [ + "~/Library/Application Support/Cursor/User", + "~/Library/Application Support/Cursor/User/globalStorage/storage.json", + "~/Library/Application Support/Cursor/Session Storage/000003.log", + "~/.config/Cursor/User/globalStorage/storage.json", +] diff --git a/recipes/ai-windsurf-cache.toml b/recipes/ai-windsurf-cache.toml index bd4ef13..ec6c69b 100644 --- a/recipes/ai-windsurf-cache.toml +++ b/recipes/ai-windsurf-cache.toml @@ -13,10 +13,21 @@ priority = 50 path_glob = "~/Library/Application Support/Windsurf/*Cache*/**" min_age_days = 7 +[[match.any_of]] +path_glob = "~/.config/Windsurf/*Cache*/**" +min_age_days = 7 + [action] type = "trash" prompt = "Move {size} of Windsurf cache to Trash?" [test] -should_match = ["~/Library/Application Support/Windsurf/CachedData/abc/module.js"] -should_not_match = ["~/Library/Application Support/Windsurf/User/keybindings.json", "~/Library/Application Support/Windsurf/User/globalStorage/state.vscdb"] +should_match = [ + "~/Library/Application Support/Windsurf/CachedData/abc/module.js", + "~/.config/Windsurf/CachedData/abc/module.js", +] +should_not_match = [ + "~/Library/Application Support/Windsurf/User/keybindings.json", + "~/Library/Application Support/Windsurf/User/globalStorage/state.vscdb", + "~/.config/Windsurf/User/keybindings.json", +] diff --git a/recipes/android-build-caches.toml b/recipes/android-build-caches.toml new file mode 100644 index 0000000..f4545de --- /dev/null +++ b/recipes/android-build-caches.toml @@ -0,0 +1,33 @@ +[recipe] +id = "android-build-caches" +name = "Android NDK .cxx & Global Build Caches" +description = "Android NDK .cxx native build intermediates and ~/.android build caches" +category = "android" +safety_level = "safe" +enabled = true +priority = 52 + +[match] +directory_name = ".cxx" +parent_must_contain_any = ["build.gradle", "build.gradle.kts"] +min_age_days = 14 + +[[match.any_of]] +path_glob = "~/.android/build-cache/**" +min_age_days = 30 + +[[match.any_of]] +path_glob = "~/.android/cache/**" +min_age_days = 30 + +[action] +type = "trash" +prompt = "Move {size} of Android native and build caches to Trash?" + +[test] +should_match = [ + "projects/mobile/app/.cxx", + "~/.android/build-cache/abc123/output", + "~/.android/cache/sdkbin-1.xml", +] +should_not_match = ["~/.android/debug.keystore", "~/.android/avd/Pixel.avd/userdata.img", "loose/.cxx"] diff --git a/recipes/android-sdk-system-images.toml b/recipes/android-sdk-system-images.toml index 3f12965..3d4d792 100644 --- a/recipes/android-sdk-system-images.toml +++ b/recipes/android-sdk-system-images.toml @@ -14,10 +14,18 @@ path_glob = "~/Library/Android/sdk/system-images/**" min_age_days = 30 min_size_mb = 200 +[[match.any_of]] +path_glob = "~/Android/Sdk/system-images/**" +min_age_days = 30 +min_size_mb = 200 + [action] type = "trash" prompt = "Move {size} of Android system images to Trash?" [test] -should_match = ["~/Library/Android/sdk/system-images/android-34/google_apis/arm64-v8a/system.img"] +should_match = [ + "~/Library/Android/sdk/system-images/android-34/google_apis/arm64-v8a/system.img", + "~/Android/Sdk/system-images/android-34/google_apis/x86_64/system.img", +] should_not_match = ["~/.android/avd/Pixel_8_API_34.avd/userdata-qemu.img", "~/Library/Android/sdk/platform-tools/adb"] diff --git a/recipes/apps-electron-cache-data.toml b/recipes/apps-electron-cache-data.toml index dd89a7a..b034ec0 100644 --- a/recipes/apps-electron-cache-data.toml +++ b/recipes/apps-electron-cache-data.toml @@ -14,10 +14,20 @@ priority = 50 path_glob = "~/Library/Application Support/*/Cache/Cache_Data/**" min_age_days = 7 +[[match.any_of]] +path_glob = "~/.config/*/Cache/Cache_Data/**" +min_age_days = 7 + [action] type = "trash" prompt = "Move {size} of app web caches to Trash?" [test] -should_match = ["~/Library/Application Support/Slack/Cache/Cache_Data/f_000001"] -should_not_match = ["~/Library/Application Support/Slack/Local Storage/leveldb/000003.log"] +should_match = [ + "~/Library/Application Support/Slack/Cache/Cache_Data/f_000001", + "~/.config/Slack/Cache/Cache_Data/f_000001", +] +should_not_match = [ + "~/Library/Application Support/Slack/Local Storage/leveldb/000003.log", + "~/.config/Slack/Local Storage/leveldb/000003.log", +] diff --git a/recipes/apps-steam-shader-cache.toml b/recipes/apps-steam-shader-cache.toml index cc4c0a4..73dbb87 100644 --- a/recipes/apps-steam-shader-cache.toml +++ b/recipes/apps-steam-shader-cache.toml @@ -15,10 +15,23 @@ path_glob = "~/Library/Application Support/Steam/steamapps/shadercache/**" min_size_mb = 50 min_age_days = 14 +[[match.any_of]] +path_glob = "~/.local/share/Steam/steamapps/shadercache/**" +min_size_mb = 50 +min_age_days = 14 + +[[match.any_of]] +path_glob = "~/.steam/steam/steamapps/shadercache/**" +min_size_mb = 50 +min_age_days = 14 + [action] type = "trash" prompt = "Move {size} of Steam shader cache to Trash?" [test] -should_match = ["~/Library/Application Support/Steam/steamapps/shadercache/570/mesa_shader_cache/index"] +should_match = [ + "~/Library/Application Support/Steam/steamapps/shadercache/570/mesa_shader_cache/index", + "~/.local/share/Steam/steamapps/shadercache/570/mesa_shader_cache/index", +] should_not_match = ["~/Library/Application Support/Steam/steamapps/common/Dota 2/game/bin/dota", "~/Library/Application Support/Steam/userdata/12345678/730/remote/save.dat"] diff --git a/recipes/browser-chrome-http-cache.toml b/recipes/browser-chrome-http-cache.toml index 9e70c51..981a18d 100644 --- a/recipes/browser-chrome-http-cache.toml +++ b/recipes/browser-chrome-http-cache.toml @@ -16,10 +16,27 @@ priority = 55 path_glob = "~/Library/Application Support/Google/Chrome/*/Cache/Cache_Data/**" min_age_days = 7 +[[match.any_of]] +path_glob = "~/.cache/google-chrome/*/Cache/Cache_Data/**" +min_age_days = 7 + +[[match.any_of]] +path_glob = "~/.cache/chromium/*/Cache/Cache_Data/**" +min_age_days = 7 + [action] type = "trash" prompt = "Move {size} of Chrome HTTP cache to Trash?" [test] -should_match = ["~/Library/Application Support/Google/Chrome/Default/Cache/Cache_Data/f_000001", "~/Library/Application Support/Google/Chrome/Profile 1/Cache/Cache_Data/f_00002a"] -should_not_match = ["~/Library/Application Support/Google/Chrome/Default/History", "~/Library/Application Support/Google/Chrome/Default/Login Data", "~/Library/Application Support/Google/Chrome/Default/Bookmarks"] +should_match = [ + "~/Library/Application Support/Google/Chrome/Default/Cache/Cache_Data/f_000001", + "~/Library/Application Support/Google/Chrome/Profile 1/Cache/Cache_Data/f_00002a", + "~/.cache/google-chrome/Default/Cache/Cache_Data/f_000001", +] +should_not_match = [ + "~/Library/Application Support/Google/Chrome/Default/History", + "~/Library/Application Support/Google/Chrome/Default/Login Data", + "~/Library/Application Support/Google/Chrome/Default/Bookmarks", + "~/.config/google-chrome/Default/Login Data", +] diff --git a/recipes/c-cpp-build-variants.toml b/recipes/c-cpp-build-variants.toml new file mode 100644 index 0000000..1c32afe --- /dev/null +++ b/recipes/c-cpp-build-variants.toml @@ -0,0 +1,58 @@ +[recipe] +id = "c-cpp-build-variants" +name = "CLion, CMake & Meson Build Directories" +# CLion defaults to cmake-build-debug / cmake-build-release; Meson creates +# builddir or _build with meson-private inside. Every match requires an +# in-directory build-system proof file so source directories never match. +description = "CLion cmake-build-* variants and Meson build directories proven by build markers" +category = "c-cpp" +safety_level = "cautious" +enabled = true +priority = 52 + +[match] +directory_name = "cmake-build-debug" +self_must_contain = ["CMakeCache.txt"] +min_age_days = 14 + +[[match.any_of]] +directory_name = "cmake-build-release" +self_must_contain = ["CMakeCache.txt"] +min_age_days = 14 + +[[match.any_of]] +directory_name = "cmake-build-relwithdebinfo" +self_must_contain = ["CMakeCache.txt"] +min_age_days = 14 + +[[match.any_of]] +directory_name = "cmake-build-minsizerel" +self_must_contain = ["CMakeCache.txt"] +min_age_days = 14 + +[[match.any_of]] +directory_name = "_build" +self_must_contain = ["CMakeCache.txt"] +min_age_days = 14 + +[[match.any_of]] +directory_name = "builddir" +self_must_contain = ["meson-private"] +min_age_days = 14 + +[[match.any_of]] +directory_name = "build" +self_must_contain = ["meson-private"] +min_age_days = 14 + +[action] +type = "trash" +prompt = "Move {size} of CMake/CLion/Meson build directories to Trash?" + +[test] +should_match = [ + "projects/engine/cmake-build-debug", + "projects/engine/cmake-build-release", + "projects/gtk-app/builddir", +] +should_not_match = ["projects/engine/src", "loose/cmake-build-debug", "loose/builddir"] diff --git a/recipes/c-cpp-ccache.toml b/recipes/c-cpp-ccache.toml index 8c29ec3..b80bb10 100644 --- a/recipes/c-cpp-ccache.toml +++ b/recipes/c-cpp-ccache.toml @@ -14,13 +14,15 @@ priority = 75 # recipe is `safe`; the age/size floors keep an actively-used cache alone. [match] path_glob = "~/Library/Caches/ccache/**" -min_size_mb = 50 min_age_days = 30 +[match.exclude] +names = ["ccache.conf"] + [[match.any_of]] path_glob = "~/.cache/ccache/**" -min_size_mb = 50 min_age_days = 30 +exclude = { names = ["ccache.conf"] } [action] type = "trash" diff --git a/recipes/c-cpp-vcpkg-cache.toml b/recipes/c-cpp-vcpkg-cache.toml new file mode 100644 index 0000000..64b86d3 --- /dev/null +++ b/recipes/c-cpp-vcpkg-cache.toml @@ -0,0 +1,27 @@ +[recipe] +id = "c-cpp-vcpkg-cache" +name = "vcpkg Binary & Download Archives" +description = "vcpkg prebuilt binary and source archive caches on Linux and macOS" +category = "c-cpp" +safety_level = "safe" +enabled = true +priority = 50 + +[match] +path_glob = "~/.cache/vcpkg/archives/**" +min_age_days = 30 + +[[match.any_of]] +path_glob = "~/Library/Caches/vcpkg/**" +min_age_days = 30 + +[action] +type = "trash" +prompt = "Move {size} of vcpkg archive caches to Trash?" + +[test] +should_match = [ + "~/.cache/vcpkg/archives/ab/ab12cd34.zip", + "~/Library/Caches/vcpkg/archives/ab/ab12cd34.zip", +] +should_not_match = ["~/vcpkg/installed/x64-linux/lib/libfmt.a", "~/.cache/other/foo"] diff --git a/recipes/clojure-build-caches.toml b/recipes/clojure-build-caches.toml new file mode 100644 index 0000000..bd890d5 --- /dev/null +++ b/recipes/clojure-build-caches.toml @@ -0,0 +1,26 @@ +[recipe] +id = "clojure-build-caches" +name = "Clojure Build & Classpath Caches" +description = "Clojure tools.deps .cpcache and Leiningen target build outputs" +category = "jvm" +safety_level = "safe" +enabled = true +priority = 50 + +[match] +directory_name = ".cpcache" +parent_must_contain = ["deps.edn"] +min_age_days = 7 + +[[match.any_of]] +directory_name = "target" +parent_must_contain = ["project.clj"] +min_age_days = 7 + +[action] +type = "trash" +prompt = "Move {size} of Clojure build caches to Trash?" + +[test] +should_match = ["projects/clj-api/.cpcache", "projects/lein-app/target"] +should_not_match = ["projects/clj-api/src", "loose/.cpcache"] diff --git a/recipes/dart-pub-cache.toml b/recipes/dart-pub-cache.toml index aed3018..90f6924 100644 --- a/recipes/dart-pub-cache.toml +++ b/recipes/dart-pub-cache.toml @@ -11,9 +11,12 @@ enabled = true priority = 50 [match] -path_glob = "~/.pub-cache/**" +path_glob = "~/.pub-cache/hosted/*/**" +min_age_days = 60 + +[[match.any_of]] +path_glob = "~/.pub-cache/git/**" min_age_days = 60 -min_size_mb = 50 [action] type = "trash" diff --git a/recipes/dart-tool-build-dirs.toml b/recipes/dart-tool-build-dirs.toml new file mode 100644 index 0000000..bcacd28 --- /dev/null +++ b/recipes/dart-tool-build-dirs.toml @@ -0,0 +1,21 @@ +[recipe] +id = "dart-tool-build-dirs" +name = "Dart & Flutter .dart_tool Caches" +description = "Per-project .dart_tool package config, build_runner and analyzer caches" +category = "dart" +safety_level = "safe" +enabled = true +priority = 52 + +[match] +directory_name = ".dart_tool" +parent_must_contain = ["pubspec.yaml"] +min_age_days = 7 + +[action] +type = "trash" +prompt = "Move {size} of .dart_tool build caches to Trash?" + +[test] +should_match = ["projects/flutter_app/.dart_tool"] +should_not_match = ["projects/flutter_app/lib", "loose/.dart_tool"] diff --git a/recipes/dotnet-http-cache.toml b/recipes/dotnet-http-cache.toml new file mode 100644 index 0000000..89550d7 --- /dev/null +++ b/recipes/dotnet-http-cache.toml @@ -0,0 +1,32 @@ +[recipe] +id = "dotnet-http-cache" +name = "NuGet HTTP Metadata Cache" +description = "NuGet HTTP and v3 feed response caches on macOS and Linux (re-fetched on restore)" +category = "dotnet" +safety_level = "safe" +enabled = true +priority = 50 + +[match] +path_glob = "~/.local/share/NuGet/http-cache/**" +min_age_days = 30 + +[[match.any_of]] +path_glob = "~/.local/share/NuGet/v3-cache/**" +min_age_days = 30 + +[[match.any_of]] +path_glob = "~/Library/Caches/NuGet/**" +min_age_days = 30 + +[action] +type = "trash" +prompt = "Move {size} of NuGet HTTP cache to Trash?" + +[test] +should_match = [ + "~/.local/share/NuGet/http-cache/abc123/index.dat", + "~/.local/share/NuGet/v3-cache/api.nuget.org/index.json", + "~/Library/Caches/NuGet/http-cache/abc123/index.dat", +] +should_not_match = ["~/.nuget/NuGet/NuGet.Config", "~/.local/share/other/index.dat"] diff --git a/recipes/dotnet-nuget-cache.toml b/recipes/dotnet-nuget-cache.toml index d288fcb..236bab7 100644 --- a/recipes/dotnet-nuget-cache.toml +++ b/recipes/dotnet-nuget-cache.toml @@ -10,7 +10,6 @@ priority = 50 [match] path_glob = "~/.nuget/packages/**" min_age_days = 60 -min_size_mb = 50 [action] type = "trash" diff --git a/recipes/dotnet-obj-dirs.toml b/recipes/dotnet-obj-dirs.toml new file mode 100644 index 0000000..31a46c5 --- /dev/null +++ b/recipes/dotnet-obj-dirs.toml @@ -0,0 +1,24 @@ +[recipe] +id = "dotnet-obj-dirs" +name = ".NET obj Build Directories" +# MSBuild and dotnet restore always write project.assets.json inside obj/, +# proving the directory is a generated .NET intermediate directory rather than +# a Wavefront 3D asset folder named obj/. +description = "Per-project .NET MSBuild intermediate obj directories (rebuilt by dotnet build)" +category = "dotnet" +safety_level = "safe" +enabled = true +priority = 55 + +[match] +directory_name = "obj" +self_must_contain = ["project.assets.json"] +min_age_days = 7 + +[action] +type = "trash" +prompt = "Move {size} of .NET obj build directories to Trash?" + +[test] +should_match = ["projects/WebApi/obj"] +should_not_match = ["projects/WebApi/src", "assets/models/obj"] diff --git a/recipes/godot-import-cache.toml b/recipes/godot-import-cache.toml new file mode 100644 index 0000000..1fdb9a4 --- /dev/null +++ b/recipes/godot-import-cache.toml @@ -0,0 +1,21 @@ +[recipe] +id = "godot-import-cache" +name = "Godot .godot Imported Asset Cache" +description = "Godot 4 .godot shader and imported resource cache (re-imported automatically)" +category = "gamedev" +safety_level = "safe" +enabled = true +priority = 50 + +[match] +directory_name = ".godot" +parent_must_contain = ["project.godot"] +min_age_days = 14 + +[action] +type = "trash" +prompt = "Move {size} of Godot .godot import caches to Trash?" + +[test] +should_match = ["projects/platformer/.godot"] +should_not_match = ["projects/platformer/scenes", "loose/.godot"] diff --git a/recipes/haskell-cabal-build-dirs.toml b/recipes/haskell-cabal-build-dirs.toml new file mode 100644 index 0000000..4d38761 --- /dev/null +++ b/recipes/haskell-cabal-build-dirs.toml @@ -0,0 +1,28 @@ +[recipe] +id = "haskell-cabal-build-dirs" +name = "Haskell Cabal dist-newstyle & Package Cache" +description = "Cabal dist-newstyle project build outputs and downloaded Hackage tarballs" +category = "haskell" +safety_level = "safe" +enabled = true +priority = 52 + +[match] +directory_name = "dist-newstyle" +parent_must_contain_any = ["cabal.project", "Setup.hs", "package.yaml"] +min_age_days = 14 + +[[match.any_of]] +path_glob = "~/.cabal/packages/**" +min_age_days = 30 + +[action] +type = "trash" +prompt = "Move {size} of Haskell Cabal build artifacts to Trash?" + +[test] +should_match = [ + "projects/hs-service/dist-newstyle", + "~/.cabal/packages/hackage.haskell.org/aeson/2.2.1.0/aeson-2.2.1.0.tar.gz", +] +should_not_match = ["projects/hs-service/src", "loose/dist-newstyle", "~/.cabal/config"] diff --git a/recipes/ide-editor-caches.toml b/recipes/ide-editor-caches.toml new file mode 100644 index 0000000..fbbb1d0 --- /dev/null +++ b/recipes/ide-editor-caches.toml @@ -0,0 +1,69 @@ +[recipe] +id = "ide-editor-caches" +name = "Neovim, JetBrains, Fleet, Eclipse & Sublime Caches" +description = "Regenerable caches and diagnostic logs for Neovim, JetBrains IDEs, Fleet, Eclipse and Sublime Text" +category = "ide" +safety_level = "safe" +enabled = true +priority = 50 + +[match] +path_glob = "~/.cache/nvim/**" +min_age_days = 14 + +[[match.any_of]] +path_glob = "~/Library/Caches/nvim/**" +min_age_days = 14 + +[[match.any_of]] +path_glob = "~/Library/Logs/JetBrains/**" +min_age_days = 14 + +[[match.any_of]] +path_glob = "~/.cache/JetBrains/*/log/**" +min_age_days = 14 + +[[match.any_of]] +path_glob = "~/Library/Caches/JetBrains/Fleet/**" +min_age_days = 14 + +[[match.any_of]] +path_glob = "~/.cache/JetBrains/Fleet/**" +min_age_days = 14 + +[[match.any_of]] +path_glob = "~/.eclipse/**/p2/org.eclipse.equinox.p2.core/cache/**" +min_age_days = 30 + +[[match.any_of]] +path_glob = "~/Library/Caches/com.sublimetext.*/**" +min_age_days = 14 + +[[match.any_of]] +path_glob = "~/.cache/sublime-text/Index/**" +min_age_days = 14 + +[[match.any_of]] +path_glob = "~/.cache/sublime-text/Cache/**" +min_age_days = 14 + +[action] +type = "trash" +prompt = "Move {size} of IDE and editor caches to Trash?" + +[test] +should_match = [ + "~/.cache/nvim/luac/init.lua", + "~/Library/Caches/nvim/lsp.log", + "~/Library/Logs/JetBrains/RustRover2025.1/idea.log", + "~/.cache/JetBrains/IntelliJIdea2025.1/log/idea.log", + "~/Library/Caches/JetBrains/Fleet/caches/index.dat", + "~/.eclipse/org.eclipse.platform_4.30/p2/org.eclipse.equinox.p2.core/cache/artifacts.xml", + "~/Library/Caches/com.sublimetext.4/Index/00001.ldb", + "~/.cache/sublime-text/Index/00001.ldb", +] +should_not_match = [ + "~/.config/nvim/init.lua", + "~/.local/share/nvim/lazy/telescope.nvim/README.md", + "~/.config/sublime-text/Packages/User/Preferences.sublime-settings", +] diff --git a/recipes/ide-vscode-caches.toml b/recipes/ide-vscode-caches.toml index 91d778d..02c0d8b 100644 --- a/recipes/ide-vscode-caches.toml +++ b/recipes/ide-vscode-caches.toml @@ -13,10 +13,39 @@ priority = 50 path_glob = "~/Library/Application Support/Code/*Cache*/**" min_age_days = 7 +[[match.any_of]] +path_glob = "~/.config/Code/*Cache*/**" +min_age_days = 7 + +[[match.any_of]] +path_glob = "~/Library/Application Support/Code - Insiders/*Cache*/**" +min_age_days = 7 + +[[match.any_of]] +path_glob = "~/.config/Code - Insiders/*Cache*/**" +min_age_days = 7 + +[[match.any_of]] +path_glob = "~/Library/Application Support/VSCodium/*Cache*/**" +min_age_days = 7 + +[[match.any_of]] +path_glob = "~/.config/VSCodium/*Cache*/**" +min_age_days = 7 + [action] type = "trash" prompt = "Move {size} of VS Code caches to Trash?" [test] -should_match = ["~/Library/Application Support/Code/CachedData/abc/extension.js", "~/Library/Application Support/Code/Code Cache/js/index_0"] -should_not_match = ["~/Library/Application Support/Code/User/keybindings.json", "~/Library/Application Support/Code/User/globalStorage/state.vscdb"] +should_match = [ + "~/Library/Application Support/Code/CachedData/abc/extension.js", + "~/Library/Application Support/Code/Code Cache/js/index_0", + "~/.config/Code/CachedData/abc/extension.js", + "~/.config/VSCodium/GPUCache/data_0", +] +should_not_match = [ + "~/Library/Application Support/Code/User/keybindings.json", + "~/Library/Application Support/Code/User/globalStorage/state.vscdb", + "~/.config/Code/User/keybindings.json", +] diff --git a/recipes/ide-vscode-extension-vsixs.toml b/recipes/ide-vscode-extension-vsixs.toml new file mode 100644 index 0000000..50e6184 --- /dev/null +++ b/recipes/ide-vscode-extension-vsixs.toml @@ -0,0 +1,56 @@ +[recipe] +id = "ide-vscode-extension-vsixs" +name = "VS Code & Cursor Cached Extension Installers" +description = "Cached downloaded .vsix extension archives and crash reports in VS Code, Cursor and VSCodium" +category = "ide" +safety_level = "safe" +enabled = true +priority = 50 + +[match] +path_glob = "~/Library/Application Support/Code/CachedExtensionVSIXs/**" +min_age_days = 14 + +[[match.any_of]] +path_glob = "~/.config/Code/CachedExtensionVSIXs/**" +min_age_days = 14 + +[[match.any_of]] +path_glob = "~/Library/Application Support/Cursor/CachedExtensionVSIXs/**" +min_age_days = 14 + +[[match.any_of]] +path_glob = "~/.config/Cursor/CachedExtensionVSIXs/**" +min_age_days = 14 + +[[match.any_of]] +path_glob = "~/Library/Application Support/VSCodium/CachedExtensionVSIXs/**" +min_age_days = 14 + +[[match.any_of]] +path_glob = "~/.config/VSCodium/CachedExtensionVSIXs/**" +min_age_days = 14 + +[[match.any_of]] +path_glob = "~/Library/Application Support/Code/Crashpad/completed/**" +min_age_days = 7 + +[[match.any_of]] +path_glob = "~/.config/Code/Crashpad/completed/**" +min_age_days = 7 + +[action] +type = "trash" +prompt = "Move {size} of cached editor VSIX installers and crash dumps to Trash?" + +[test] +should_match = [ + "~/Library/Application Support/Code/CachedExtensionVSIXs/ms-python.python-2025.1.0", + "~/.config/Code/CachedExtensionVSIXs/rust-lang.rust-analyzer-0.4.0", + "~/.config/Cursor/CachedExtensionVSIXs/anysphere.pyright-1.0.0", + "~/.config/Code/Crashpad/completed/dump.dmp", +] +should_not_match = [ + "~/.vscode/extensions/rust-lang.rust-analyzer/package.json", + "~/.config/Code/User/settings.json", +] diff --git a/recipes/jvm-gradle-caches.toml b/recipes/jvm-gradle-caches.toml index 756c26c..e03e0c5 100644 --- a/recipes/jvm-gradle-caches.toml +++ b/recipes/jvm-gradle-caches.toml @@ -13,7 +13,7 @@ priority = 50 [match] path_glob = "~/.gradle/caches/**" min_age_days = 30 -min_size_mb = 50 +min_size_mb = 10 [action] type = "trash" diff --git a/recipes/jvm-maven-target-dirs.toml b/recipes/jvm-maven-target-dirs.toml new file mode 100644 index 0000000..60eb9ae --- /dev/null +++ b/recipes/jvm-maven-target-dirs.toml @@ -0,0 +1,21 @@ +[recipe] +id = "jvm-maven-target-dirs" +name = "Maven target Build Directories" +description = "Maven project target build output directories (rebuilt by mvn package)" +category = "jvm" +safety_level = "safe" +enabled = true +priority = 55 + +[match] +directory_name = "target" +parent_must_contain = ["pom.xml"] +min_age_days = 7 + +[action] +type = "trash" +prompt = "Move {size} of Maven target directories to Trash?" + +[test] +should_match = ["projects/spring-service/target"] +should_not_match = ["projects/spring-service/src", "loose/target"] diff --git a/recipes/jvm-project-gradle-dirs.toml b/recipes/jvm-project-gradle-dirs.toml new file mode 100644 index 0000000..6b75e3d --- /dev/null +++ b/recipes/jvm-project-gradle-dirs.toml @@ -0,0 +1,21 @@ +[recipe] +id = "jvm-project-gradle-dirs" +name = "Gradle Project .gradle Caches" +description = "Per-project .gradle task history and configuration caches (rebuilt by gradle)" +category = "jvm" +safety_level = "safe" +enabled = true +priority = 52 + +[match] +directory_name = ".gradle" +parent_must_contain_any = ["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"] +min_age_days = 14 + +[action] +type = "trash" +prompt = "Move {size} of project .gradle caches to Trash?" + +[test] +should_match = ["projects/android-app/.gradle"] +should_not_match = ["~/.gradle/gradle.properties", "loose/.gradle"] diff --git a/recipes/jvm-scala-build-dirs.toml b/recipes/jvm-scala-build-dirs.toml new file mode 100644 index 0000000..7caba25 --- /dev/null +++ b/recipes/jvm-scala-build-dirs.toml @@ -0,0 +1,35 @@ +[recipe] +id = "jvm-scala-build-dirs" +name = "Scala sbt, Bloop & Scala CLI Caches" +description = "Scala sbt target, .bloop and .scala-build compile caches" +category = "jvm" +safety_level = "safe" +enabled = true +priority = 52 + +[match] +directory_name = "target" +parent_must_contain = ["build.sbt"] +min_age_days = 7 + +[[match.any_of]] +directory_name = ".bloop" +parent_must_contain_any = ["build.sbt", "build.sc", "project.scala"] +min_age_days = 7 + +[[match.any_of]] +directory_name = ".scala-build" +parent_must_contain_any = ["build.sbt", "project.scala"] +min_age_days = 7 + +[action] +type = "trash" +prompt = "Move {size} of Scala build caches to Trash?" + +[test] +should_match = [ + "projects/akka-service/target", + "projects/akka-service/.bloop", + "projects/script/.scala-build", +] +should_not_match = ["projects/akka-service/src", "loose/.bloop"] diff --git a/recipes/node-bundler-caches.toml b/recipes/node-bundler-caches.toml index 861cf5b..6f238c6 100644 --- a/recipes/node-bundler-caches.toml +++ b/recipes/node-bundler-caches.toml @@ -2,32 +2,34 @@ id = "node-bundler-caches" name = "Node Bundler Caches" # Each tool owns one subdirectory of node_modules/.cache and rebuilds it on -# the next run. The parent_must_contain marker keeps the glob inside real -# Node projects, so a stray .cache directory never matches. -description = "Per-project babel-loader, SWC, Nx and Storybook caches under node_modules/.cache" +# the next run. Scoped strictly to known bundler/linter subdirectories under +# node_modules/.cache so other tool state is untouched. +description = "Per-project babel-loader, SWC, Nx, Storybook, Webpack, ESLint and Terser caches under node_modules/.cache" category = "node" safety_level = "safe" enabled = true -priority = 50 +priority = 52 [match] path_glob = "**/node_modules/.cache/babel-loader/**" -parent_must_contain = ["package.json"] -# SWC: the Rust compiler's per-project cache. [[match.any_of]] path_glob = "**/node_modules/.cache/swc/**" -parent_must_contain = ["package.json"] -# Nx: the monorepo task cache. [[match.any_of]] path_glob = "**/node_modules/.cache/nx/**" -parent_must_contain = ["package.json"] -# Storybook: the built-storybook cache. [[match.any_of]] path_glob = "**/node_modules/.cache/storybook/**" -parent_must_contain = ["package.json"] + +[[match.any_of]] +path_glob = "**/node_modules/.cache/webpack/**" + +[[match.any_of]] +path_glob = "**/node_modules/.cache/eslint/**" + +[[match.any_of]] +path_glob = "**/node_modules/.cache/terser-webpack-plugin/**" [action] type = "trash" @@ -39,9 +41,10 @@ should_match = [ "projects/app/node_modules/.cache/swc/abc", "projects/app/node_modules/.cache/nx/abc", "projects/app/node_modules/.cache/storybook/abc", + "projects/app/node_modules/.cache/webpack/default-development/0.pack", ] should_not_match = [ "projects/app/node_modules/.cache/other/abc", "projects/app/.cache/babel-loader/abc", - "loose/node_modules/.cache/babel-loader/abc", + "projects/app/node_modules/react/index.js", ] diff --git a/recipes/node-modern-framework-dirs.toml b/recipes/node-modern-framework-dirs.toml new file mode 100644 index 0000000..33360c9 --- /dev/null +++ b/recipes/node-modern-framework-dirs.toml @@ -0,0 +1,65 @@ +[recipe] +id = "node-modern-framework-dirs" +name = "Modern Web Framework Build Caches" +description = "SvelteKit, Nuxt, Astro, Angular, Docusaurus, Expo and Wrangler generated build caches" +category = "node" +safety_level = "safe" +enabled = true +priority = 52 + +[match] +directory_name = ".svelte-kit" +parent_must_contain = ["package.json"] +min_age_days = 7 + +[[match.any_of]] +directory_name = ".nuxt" +parent_must_contain = ["package.json"] +min_age_days = 7 + +[[match.any_of]] +directory_name = ".output" +parent_must_contain_any = ["nuxt.config.ts", "nuxt.config.js", "nitro.config.ts"] +min_age_days = 7 + +[[match.any_of]] +directory_name = ".astro" +parent_must_contain = ["package.json"] +min_age_days = 7 + +[[match.any_of]] +directory_name = ".angular" +parent_must_contain = ["angular.json"] +min_age_days = 7 + +[[match.any_of]] +directory_name = ".docusaurus" +parent_must_contain = ["package.json"] +min_age_days = 7 + +[[match.any_of]] +directory_name = ".expo" +parent_must_contain = ["package.json"] +min_age_days = 7 + +[[match.any_of]] +directory_name = ".wrangler" +parent_must_contain = ["package.json"] +min_age_days = 7 + +[action] +type = "trash" +prompt = "Move {size} of modern web framework build caches to Trash?" + +[test] +should_match = [ + "projects/web/.svelte-kit", + "projects/web/.nuxt", + "projects/web/.output", + "projects/web/.astro", + "projects/web/.angular", + "projects/docs/.docusaurus", + "projects/mobile/.expo", + "projects/worker/.wrangler", +] +should_not_match = ["projects/web/src", "loose/.svelte-kit", "loose/.output"] diff --git a/recipes/node-nx-cache-dirs.toml b/recipes/node-nx-cache-dirs.toml new file mode 100644 index 0000000..9a3da14 --- /dev/null +++ b/recipes/node-nx-cache-dirs.toml @@ -0,0 +1,30 @@ +[recipe] +id = "node-nx-cache-dirs" +name = "Nx Workspace Task Caches" +description = "Nx monorepo .nx/cache and .nx/workspace-data task computation caches" +category = "node" +safety_level = "safe" +enabled = true +priority = 52 + +[match] +path_glob = "**/.nx/cache/**" +min_age_days = 7 + +[[match.any_of]] +path_glob = "**/.nx/workspace-data/**" +min_age_days = 7 + +[action] +type = "trash" +prompt = "Move {size} of Nx workspace caches to Trash?" + +[test] +should_match = [ + "projects/monorepo/.nx/cache/123456789/outputs", + "projects/monorepo/.nx/workspace-data/project-graph.json", +] +should_not_match = [ + "projects/monorepo/.nx/installation/package.json", + "projects/monorepo/nx.json", +] diff --git a/recipes/node-pnpm-store.toml b/recipes/node-pnpm-store.toml index 511aba2..d8be57b 100644 --- a/recipes/node-pnpm-store.toml +++ b/recipes/node-pnpm-store.toml @@ -12,12 +12,20 @@ priority = 50 [match] path_glob = "~/Library/pnpm/store/**" -min_size_mb = 200 +min_size_mb = 100 + +[[match.any_of]] +path_glob = "~/.local/share/pnpm/store/**" +min_size_mb = 100 + +[[match.any_of]] +path_glob = "~/.pnpm-store/**" +min_size_mb = 100 [action] type = "trash" prompt = "Move {size} of pnpm global store to Trash?" [test] -should_match = ["~/Library/pnpm/store/v3/files/foo"] +should_match = ["~/Library/pnpm/store/v3/files/foo", "~/.local/share/pnpm/store/v3/files/foo"] should_not_match = ["~/.cargo", "~/Library/pnpm/global/5/node_modules/typescript/package.json"] diff --git a/recipes/ocaml-dune-build-dirs.toml b/recipes/ocaml-dune-build-dirs.toml new file mode 100644 index 0000000..0396c17 --- /dev/null +++ b/recipes/ocaml-dune-build-dirs.toml @@ -0,0 +1,21 @@ +[recipe] +id = "ocaml-dune-build-dirs" +name = "OCaml Dune _build Directories" +description = "OCaml and ReasonML Dune _build project compilation directories" +category = "ocaml" +safety_level = "safe" +enabled = true +priority = 52 + +[match] +directory_name = "_build" +parent_must_contain = ["dune-project"] +min_age_days = 7 + +[action] +type = "trash" +prompt = "Move {size} of OCaml Dune _build directories to Trash?" + +[test] +should_match = ["projects/ocaml-compiler/_build"] +should_not_match = ["projects/ocaml-compiler/lib", "loose/_build"] diff --git a/recipes/php-tool-caches.toml b/recipes/php-tool-caches.toml new file mode 100644 index 0000000..a8625e8 --- /dev/null +++ b/recipes/php-tool-caches.toml @@ -0,0 +1,33 @@ +[recipe] +id = "php-tool-caches" +name = "PHP Static Analysis & Test Caches" +description = "PHPUnit .phpunit.cache, PHPStan and Psalm analysis caches" +category = "php" +safety_level = "safe" +enabled = true +priority = 50 + +[match] +directory_name = ".phpunit.cache" +parent_must_contain = ["composer.json"] +min_age_days = 7 + +[[match.any_of]] +path_glob = "~/.cache/phpstan/**" +min_age_days = 14 + +[[match.any_of]] +path_glob = "~/.cache/psalm/**" +min_age_days = 14 + +[action] +type = "trash" +prompt = "Move {size} of PHP analysis and test caches to Trash?" + +[test] +should_match = [ + "projects/laravel-api/.phpunit.cache", + "~/.cache/phpstan/resultCache.php", + "~/.cache/psalm/abc123", +] +should_not_match = ["projects/laravel-api/app", "loose/.phpunit.cache"] diff --git a/recipes/python-pixi-cache.toml b/recipes/python-pixi-cache.toml index e619220..89d28e9 100644 --- a/recipes/python-pixi-cache.toml +++ b/recipes/python-pixi-cache.toml @@ -9,12 +9,16 @@ priority = 50 [match] path_glob = "~/.cache/rattler/**" -min_size_mb = 100 +min_size_mb = 50 + +[[match.any_of]] +path_glob = "~/Library/Caches/rattler/**" +min_size_mb = 50 [action] type = "trash" prompt = "Move {size} of Pixi/rattler package cache to Trash?" [test] -should_match = ["~/.cache/rattler/pkgs/foo"] +should_match = ["~/.cache/rattler/pkgs/foo", "~/Library/Caches/rattler/pkgs/foo"] should_not_match = ["~/.cache/other"] diff --git a/recipes/python-poetry-cache.toml b/recipes/python-poetry-cache.toml index 2c7cb59..9ef0226 100644 --- a/recipes/python-poetry-cache.toml +++ b/recipes/python-poetry-cache.toml @@ -18,10 +18,11 @@ min_size_mb = 50 [match.exclude] paths = ["~/Library/Caches/pypoetry/virtualenvs", "~/Library/Caches/pypoetry/virtualenvs/**"] -# Linux (XDG): Poetry's cache lives in ~/.cache/pypoetry. +# Linux (XDG): Poetry's cache lives in ~/.cache/pypoetry (virtualenvs excluded too). [[match.any_of]] min_size_mb = 50 path_glob = "~/.cache/pypoetry/**" +exclude = { paths = ["~/.cache/pypoetry/virtualenvs", "~/.cache/pypoetry/virtualenvs/**"] } [action] type = "trash" @@ -35,5 +36,6 @@ should_match = [ should_not_match = [ "~/Library/Caches/Homebrew", "~/Library/Caches/pypoetry/virtualenvs/api-abc123-py3.12/bin/python", + "~/.cache/pypoetry/virtualenvs/api-abc123-py3.12/bin/python", "~/.cache/other/foo", ] diff --git a/recipes/python-tool-caches.toml b/recipes/python-tool-caches.toml index 5a36a62..d79b9ce 100644 --- a/recipes/python-tool-caches.toml +++ b/recipes/python-tool-caches.toml @@ -1,8 +1,8 @@ [recipe] id = "python-tool-caches" name = "Python Tool Caches" -description = "mypy type-check cache" -# Regenerated by: next mypy run rebuilds the cache. +description = "Per-project mypy, pytest, hypothesis, tox, nox and pytype caches" +# Regenerated by: next test, lint, or type-check run rebuilds these caches. category = "python" safety_level = "safe" enabled = true @@ -12,10 +12,37 @@ priority = 50 directory_name = ".mypy_cache" parent_must_contain_any = ["pyproject.toml", "setup.py", "setup.cfg", "mypy.ini", "requirements.txt"] +[[match.any_of]] +directory_name = ".pytest_cache" +parent_must_contain_any = ["pyproject.toml", "setup.py", "setup.cfg", "pytest.ini", "requirements.txt", "conftest.py"] + +[[match.any_of]] +directory_name = ".hypothesis" +parent_must_contain_any = ["pyproject.toml", "setup.py", "setup.cfg", "pytest.ini", "requirements.txt"] + +[[match.any_of]] +directory_name = ".tox" +parent_must_contain_any = ["tox.ini", "pyproject.toml"] +min_age_days = 14 + +[[match.any_of]] +directory_name = ".nox" +parent_must_contain_any = ["noxfile.py", "pyproject.toml"] +min_age_days = 14 + +[[match.any_of]] +directory_name = ".pytype" +parent_must_contain_any = ["pyproject.toml", "setup.py", "setup.cfg"] + [action] type = "trash" prompt = "Move {size} of Python tool caches to Trash?" [test] -should_match = ["projects/app/.mypy_cache"] -should_not_match = ["projects/app/src", "loose/.mypy_cache"] +should_match = [ + "projects/app/.mypy_cache", + "projects/app/.pytest_cache", + "projects/app/.tox", + "projects/app/.nox", +] +should_not_match = ["projects/app/src", "loose/.mypy_cache", "loose/.pytest_cache"] diff --git a/recipes/python-uv-cache.toml b/recipes/python-uv-cache.toml index cabf324..baab82d 100644 --- a/recipes/python-uv-cache.toml +++ b/recipes/python-uv-cache.toml @@ -10,12 +10,16 @@ priority = 50 [match] path_glob = "~/.cache/uv/**" -min_size_mb = 100 +min_size_mb = 20 + +[[match.any_of]] +path_glob = "~/Library/Caches/uv/**" +min_size_mb = 20 [action] type = "trash" prompt = "Move {size} of uv cache to Trash?" [test] -should_match = ["~/.cache/uv/archive/foo"] +should_match = ["~/.cache/uv/archive/foo", "~/Library/Caches/uv/archive/foo"] should_not_match = ["~/.cache/other", "~/.local/share/uv/python/cpython-3.12.6-macos-aarch64-none/bin/python3"] diff --git a/recipes/rust-cargo-git.toml b/recipes/rust-cargo-git.toml index 2da7351..1fbb711 100644 --- a/recipes/rust-cargo-git.toml +++ b/recipes/rust-cargo-git.toml @@ -10,7 +10,7 @@ priority = 50 # Regenerated by: cargo re-checks-out from the bare clones in `git/db` (or # re-fetches) on the next build that needs them. [match] -path_glob = "~/.cargo/git/checkouts/**" +path_glob = "~/.cargo/git/checkouts/*/**" min_age_days = 30 [action] diff --git a/recipes/rust-cargo-registry-cache.toml b/recipes/rust-cargo-registry-cache.toml index b265f5c..fdf990d 100644 --- a/recipes/rust-cargo-registry-cache.toml +++ b/recipes/rust-cargo-registry-cache.toml @@ -9,7 +9,7 @@ enabled = true priority = 50 [match] -path_glob = "~/.cargo/registry/cache/**" +path_glob = "~/.cargo/registry/cache/*/**" min_age_days = 30 [action] diff --git a/recipes/rust-cargo-registry-src.toml b/recipes/rust-cargo-registry-src.toml index 19f8fb4..010a73d 100644 --- a/recipes/rust-cargo-registry-src.toml +++ b/recipes/rust-cargo-registry-src.toml @@ -9,7 +9,7 @@ enabled = true priority = 50 [match] -path_glob = "~/.cargo/registry/src/**" +path_glob = "~/.cargo/registry/src/*/**" min_age_days = 30 [action] diff --git a/recipes/swift-derived-data.toml b/recipes/swift-derived-data.toml index f6d6ecf..fb13335 100644 --- a/recipes/swift-derived-data.toml +++ b/recipes/swift-derived-data.toml @@ -13,7 +13,7 @@ priority = 50 # their largest ancestor. [match] path_glob = "~/Library/Developer/Xcode/DerivedData/**" -min_size_mb = 50 +min_size_mb = 10 [action] type = "trash" From 5f210f3e17a3a0ed8aac3533ff1a02c7d1d18688 Mon Sep 17 00:00:00 2001 From: ibnbd <2509519+ibnbd@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:06:27 +0000 Subject: [PATCH 2/8] chore(ci): emit structured diagnostics in dev gate Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/dev.yml | 46 +++++++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index c68a80b..f2fda3f 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -99,17 +99,45 @@ jobs: echo "pinned=$pinned actual=$actual" [ "$pinned" = "$actual" ] - - name: Check formatting - run: cargo fmt --all --check - - - name: Clippy (deny warnings) - run: cargo clippy --workspace --all-targets --all-features -- -D warnings - - - name: Test (tee full log) + - name: Check formatting, Clippy, and Tests (emit annotations) shell: bash run: | - set -o pipefail - cargo test --workspace --all-features 2>&1 | tee test-output.txt + python3 - <<'PY' + import base64, subprocess, sys + + def emit(kind, title, text): + b64 = base64.b64encode(text.encode("utf-8", errors="replace")).decode("ascii") + chunk_size = 3000 + for idx in range(0, min(len(b64), chunk_size * 15), chunk_size): + part = b64[idx:idx + chunk_size] + print(f"::{kind} title={title}_{idx // chunk_size:02d}::{part}", flush=True) + + subprocess.run(["cargo", "fmt", "--all"], check=False) + fmt_diff = subprocess.run(["git", "diff"], capture_output=True, text=True).stdout + if fmt_diff: + emit("warning", "FMTDIFF", fmt_diff) + + clippy = subprocess.run( + ["cargo", "clippy", "--workspace", "--all-targets", "--all-features", "--message-format=short", "--", "-D", "warnings"], + capture_output=True, text=True + ) + if clippy.returncode != 0: + emit("error", "CLIPPY", clippy.stdout + "\n" + clippy.stderr) + sys.exit(clippy.returncode) + + test = subprocess.run( + ["cargo", "test", "--workspace", "--all-features", "--", "--nocapture"], + capture_output=True, text=True + ) + if test.returncode != 0: + out = (test.stdout + "\n" + test.stderr)[-35000:] + emit("error", "TESTS", out) + sys.exit(test.returncode) + + if fmt_diff: + sys.exit(1) + print("::notice title=ALL_GREEN::fmt+clippy+testsclean", flush=True) + PY - name: Upload test output if: always() From f3399e3fe0eb4b8428a5fe4d4d206b0c941cddba Mon Sep 17 00:00:00 2001 From: ibnbd <2509519+ibnbd@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:06:52 +0000 Subject: [PATCH 3/8] chore(ci): run rust diagnostic job on PR push Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/dev.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index f2fda3f..16d5074 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -66,7 +66,6 @@ jobs: rust: name: Rust gates (${{ matrix.os }}) - if: github.event_name == 'workflow_dispatch' runs-on: ${{ matrix.os }} strategy: fail-fast: false From 351e31cb69f9b3f65fdf4fad05db60c17b17212f Mon Sep 17 00:00:00 2001 From: ibnbd <2509519+ibnbd@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:11:37 +0000 Subject: [PATCH 4/8] Apply rustfmt and fix clippy lints in scanner and antivirus Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/dev.yml | 2 +- .../cleanton-antivirus/src/engines/hashdb.rs | 5 +- crates/cleanton-antivirus/src/scanner.rs | 11 ++-- crates/cleanton-app/src/main.rs | 25 +++++----- crates/cleanton-engine/src/background.rs | 8 +-- crates/cleanton-engine/src/orchestrator.rs | 3 +- crates/cleanton-engine/src/security.rs | 7 +-- crates/cleanton-recipes/src/matcher.rs | 12 ++--- crates/cleanton-scanner/src/capacity_ffi.rs | 18 ++----- crates/cleanton-scanner/src/project.rs | 30 +++++++---- crates/cleanton-scanner/src/walker.rs | 11 ++-- crates/cleanton-services/src/manager.rs | 12 ++--- crates/cleanton-services/src/systemd.rs | 50 +++++++++---------- crates/cleanton-ui/src/screens/dashboard.rs | 5 +- 14 files changed, 100 insertions(+), 99 deletions(-) diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 16d5074..49fe8bd 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -117,7 +117,7 @@ jobs: emit("warning", "FMTDIFF", fmt_diff) clippy = subprocess.run( - ["cargo", "clippy", "--workspace", "--all-targets", "--all-features", "--message-format=short", "--", "-D", "warnings"], + ["cargo", "clippy", "--workspace", "--all-targets", "--all-features", "--keep-going", "--message-format=short", "--", "-D", "warnings"], capture_output=True, text=True ) if clippy.returncode != 0: diff --git a/crates/cleanton-antivirus/src/engines/hashdb.rs b/crates/cleanton-antivirus/src/engines/hashdb.rs index eb293b7..e34607a 100644 --- a/crates/cleanton-antivirus/src/engines/hashdb.rs +++ b/crates/cleanton-antivirus/src/engines/hashdb.rs @@ -648,7 +648,10 @@ fn contains_eicar_string(data: &[u8]) -> bool { let last_start = data.len() - eicar.len(); let mut pos = 0usize; while pos <= last_start { - let Some(rel) = data.get(pos..=last_start).and_then(|s| s.iter().position(|&b| b == b'X')) else { + let Some(rel) = data + .get(pos..=last_start) + .and_then(|s| s.iter().position(|&b| b == b'X')) + else { return false; }; let idx = pos + rel; diff --git a/crates/cleanton-antivirus/src/scanner.rs b/crates/cleanton-antivirus/src/scanner.rs index 0d73e69..28755ae 100644 --- a/crates/cleanton-antivirus/src/scanner.rs +++ b/crates/cleanton-antivirus/src/scanner.rs @@ -132,23 +132,22 @@ impl Scanner { // parallel on a bounded worker pool while preserving canonical engine // ordering. Small per-package scans stay inline to avoid thread-spawn // overhead when callers already parallelize across packages. - let results: Vec<(EngineStat, Vec)> = + type EngineRunOutput = (EngineStat, Vec); + let results: Vec = if loaded.files.len() >= 64 && self.engines.len() >= 2 { let next = std::sync::atomic::AtomicUsize::new(0); let workers = std::thread::available_parallelism() .map_or(2, std::num::NonZeroUsize::get) .clamp(1, 4) .min(self.engines.len()); - let slots: std::sync::Mutex< - Vec)>>, - > = std::sync::Mutex::new((0..self.engines.len()).map(|_| None).collect()); + let slots: std::sync::Mutex>> = + std::sync::Mutex::new((0..self.engines.len()).map(|_| None).collect()); std::thread::scope(|scope| { for _ in 0..workers { scope.spawn(|| { loop { - let idx = - next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let idx = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let Some(engine) = self.engines.get(idx) else { break; }; diff --git a/crates/cleanton-app/src/main.rs b/crates/cleanton-app/src/main.rs index 8e2a42c..b910080 100644 --- a/crates/cleanton-app/src/main.rs +++ b/crates/cleanton-app/src/main.rs @@ -747,12 +747,13 @@ fn main() -> Result<(), slint::PlatformError> { }); let ui_weak = ui.as_weak(); - ui.global::().on_dismiss_memory_offer(move || { - MEMORY_OFFER_DISMISSED.store(true, Ordering::Relaxed); - if let Some(ui) = ui_weak.upgrade() { - ui.global::().set_memory_offer_active(false); - } - }); + ui.global::() + .on_dismiss_memory_offer(move || { + MEMORY_OFFER_DISMISSED.store(true, Ordering::Relaxed); + if let Some(ui) = ui_weak.upgrade() { + ui.global::().set_memory_offer_active(false); + } + }); // ── Wire: Services refresh callback ──────────────────────────────────── let ui_weak = ui.as_weak(); @@ -2655,12 +2656,12 @@ fn populate_settings(state: &AppState, config: &CleantonConfig) { state.set_setting_max_action_gb( i32::try_from(config.safety.max_single_action_gb).unwrap_or(i32::MAX), ); - state.set_setting_disk_pressure_percent( - i32::from(config.background.disk_pressure_percent.unwrap_or(0)), - ); - state.set_setting_memory_pressure_percent( - i32::from(config.background.memory_pressure_percent.unwrap_or(0)), - ); + state.set_setting_disk_pressure_percent(i32::from( + config.background.disk_pressure_percent.unwrap_or(0), + )); + state.set_setting_memory_pressure_percent(i32::from( + config.background.memory_pressure_percent.unwrap_or(0), + )); } /// Read the edited Settings controls back into `config`. diff --git a/crates/cleanton-engine/src/background.rs b/crates/cleanton-engine/src/background.rs index ae0345d..df67b99 100644 --- a/crates/cleanton-engine/src/background.rs +++ b/crates/cleanton-engine/src/background.rs @@ -374,8 +374,7 @@ impl PackageWatcher { let clear_below = threshold.saturating_sub(PRESSURE_HYSTERESIS_PERCENT); if used_percent >= threshold && !self.memory_pressure_active { self.memory_pressure_active = true; - let (suggestion, offer) = - build_memory_offer(used_percent, memory.available_bytes); + let (suggestion, offer) = build_memory_offer(used_percent, memory.available_bytes); events.push(WatchEvent::ResourcePressure { kind: ResourceKind::Memory, used_percent, @@ -986,7 +985,10 @@ mod tests { used_bytes: 90 * 1024 * 1024 * 1024, }; let (advice, offer) = build_disk_offer(90, &cap, temp.path()); - assert!(advice.contains("pip"), "expected cache candidate in {advice}"); + assert!( + advice.contains("pip"), + "expected cache candidate in {advice}" + ); assert!(matches!(offer, CleanupOffer::Disk { .. })); if let CleanupOffer::Disk { candidates, diff --git a/crates/cleanton-engine/src/orchestrator.rs b/crates/cleanton-engine/src/orchestrator.rs index a08a7f4..5079a11 100644 --- a/crates/cleanton-engine/src/orchestrator.rs +++ b/crates/cleanton-engine/src/orchestrator.rs @@ -162,7 +162,8 @@ impl Orchestrator { let classifier = MlEngine::embedded(); let cpu_work = async { std::thread::scope(|scope| { - let analyzed_handle = scope.spawn(|| self.analyze_with_classifier(scan, &classifier)); + let analyzed_handle = + scope.spawn(|| self.analyze_with_classifier(scan, &classifier)); let mut suggestions = detect_suggestions(scan); crate::ml::annotate_suggestions(&classifier, &mut suggestions, scan); let analyzed = analyzed_handle diff --git a/crates/cleanton-engine/src/security.rs b/crates/cleanton-engine/src/security.rs index 134a27e..da265ee 100644 --- a/crates/cleanton-engine/src/security.rs +++ b/crates/cleanton-engine/src/security.rs @@ -2544,9 +2544,10 @@ pub fn scan_targets_with_options( && advisories.is_empty() && !fingerprint.is_empty() && let Some(baseline) = preloaded_baseline.as_ref() - && let Some(entry) = baseline.entries.get( - &security_baseline::identity_key(candidate.source, &candidate.name), - ) + && let Some(entry) = baseline.entries.get(&security_baseline::identity_key( + candidate.source, + &candidate.name, + )) && entry.fingerprint == fingerprint { let verdict = match entry.verdict_at_baseline.as_str() { diff --git a/crates/cleanton-recipes/src/matcher.rs b/crates/cleanton-recipes/src/matcher.rs index 93e6598..c34014b 100644 --- a/crates/cleanton-recipes/src/matcher.rs +++ b/crates/cleanton-recipes/src/matcher.rs @@ -692,10 +692,7 @@ impl CompiledSpec { Pattern::new(&expanded).ok() }) .collect(); - let file_name = spec - .file_name - .as_deref() - .map(CompiledNameMatcher::compile); + let file_name = spec.file_name.as_deref().map(CompiledNameMatcher::compile); let file_extensions = spec.file_extension.as_ref().map(|exts| { exts.iter() .map(|ext| ext.strip_prefix('.').unwrap_or(ext).to_owned()) @@ -728,7 +725,7 @@ impl CompiledSpec { entry_ext: Option<&str>, dir_contains: &impl Fn(&Path, &str) -> bool, ) -> bool { - if let Some( expected ) = &self.directory_name { + if let Some(expected) = &self.directory_name { if entry.kind != FileKind::Directory || entry_name != Some(expected.as_str()) { return false; } @@ -1484,7 +1481,10 @@ mod tests { ("~/Library/Caches/pip/http/wheel.whl", FileKind::File), ("~/.cache/pip/http/wheel.whl", FileKind::File), ("~/Projects/app/__pycache__", FileKind::Directory), - ("~/Projects/app/__pycache__/mod.cpython-312.pyc", FileKind::File), + ( + "~/Projects/app/__pycache__/mod.cpython-312.pyc", + FileKind::File, + ), ("~/Projects/app/target", FileKind::Directory), ("~/Projects/app/node_modules", FileKind::Directory), ("~/Projects/app/src/main.rs", FileKind::File), diff --git a/crates/cleanton-scanner/src/capacity_ffi.rs b/crates/cleanton-scanner/src/capacity_ffi.rs index b8874a8..65cd424 100644 --- a/crates/cleanton-scanner/src/capacity_ffi.rs +++ b/crates/cleanton-scanner/src/capacity_ffi.rs @@ -463,11 +463,7 @@ fn collect_user_process_memory() -> Vec { flavor: libc::c_int, buffer: *mut libc::rusage_info_v2, ) -> libc::c_int; - fn proc_name( - pid: libc::c_int, - buffer: *mut libc::c_void, - buffersize: u32, - ) -> libc::c_int; + fn proc_name(pid: libc::c_int, buffer: *mut libc::c_void, buffersize: u32) -> libc::c_int; } let mut pids = vec![0_i32; MAX_PIDS]; @@ -554,6 +550,7 @@ fn collect_user_process_memory() -> Vec { /// Ask the C/system allocator to return unused free heap pages back to the OS kernel. /// Safe to invoke after dropping large scan results or when memory pressure is high. +#[must_use] pub fn reclaim_process_memory() -> bool { reclaim_process_memory_impl() } @@ -571,20 +568,15 @@ fn reclaim_process_memory_impl() -> bool { #[cfg(target_os = "macos")] fn reclaim_process_memory_impl() -> bool { unsafe extern "C" { - fn malloc_zone_pressure_relief( - zone: *mut libc::c_void, - goal: libc::size_t, - ) -> libc::size_t; + fn malloc_zone_pressure_relief(zone: *mut libc::c_void, goal: libc::size_t) + -> libc::size_t; } // SAFETY: Passing a null zone pointer instructs libsystem_malloc to relieve unused // cached pages across all malloc zones; `goal = 0` requests maximal safe relief. unsafe { malloc_zone_pressure_relief(std::ptr::null_mut(), 0) > 0 } } -#[cfg(not(any( - all(target_os = "linux", target_env = "gnu"), - target_os = "macos" -)))] +#[cfg(not(any(all(target_os = "linux", target_env = "gnu"), target_os = "macos")))] fn reclaim_process_memory_impl() -> bool { false } diff --git a/crates/cleanton-scanner/src/project.rs b/crates/cleanton-scanner/src/project.rs index ff80fcc..b6936ec 100644 --- a/crates/cleanton-scanner/src/project.rs +++ b/crates/cleanton-scanner/src/project.rs @@ -143,11 +143,7 @@ fn marker_for(path: &Path) -> Option { "package.json" => Some(Ecosystem::Node), "pyproject.toml" | "setup.py" | "setup.cfg" => Some(Ecosystem::Python), "go.mod" => Some(Ecosystem::Go), - "pom.xml" - | "build.gradle" - | "build.gradle.kts" - | "build.sbt" - | "deps.edn" + "pom.xml" | "build.gradle" | "build.gradle.kts" | "build.sbt" | "deps.edn" | "project.clj" => Some(Ecosystem::Jvm), "CMakeLists.txt" | "meson.build" => Some(Ecosystem::CCpp), "pubspec.yaml" => Some(Ecosystem::Dart), @@ -305,10 +301,26 @@ mod tests { ]; let detected = detect_project_roots(paths); assert_eq!(detected.len(), 5); - assert!(detected.iter().any(|p| p.ecosystem == Ecosystem::Node && p.path == Path::new("/workspace/web"))); - assert!(detected.iter().any(|p| p.ecosystem == Ecosystem::Dotnet && p.path == Path::new("/workspace/api"))); - assert!(detected.iter().any(|p| p.ecosystem == Ecosystem::Jvm && p.path == Path::new("/workspace/java"))); - assert!(detected.iter().any(|p| p.ecosystem == Ecosystem::CCpp && p.path == Path::new("/workspace/cpp"))); + assert!( + detected + .iter() + .any(|p| p.ecosystem == Ecosystem::Node && p.path == Path::new("/workspace/web")) + ); + assert!( + detected + .iter() + .any(|p| p.ecosystem == Ecosystem::Dotnet && p.path == Path::new("/workspace/api")) + ); + assert!( + detected + .iter() + .any(|p| p.ecosystem == Ecosystem::Jvm && p.path == Path::new("/workspace/java")) + ); + assert!( + detected + .iter() + .any(|p| p.ecosystem == Ecosystem::CCpp && p.path == Path::new("/workspace/cpp")) + ); assert!(detected.iter().any(|p| p.ecosystem == Ecosystem::Dart && p.path == Path::new("/workspace/mobile"))); } diff --git a/crates/cleanton-scanner/src/walker.rs b/crates/cleanton-scanner/src/walker.rs index 12fd9e0..3ea8dbf 100644 --- a/crates/cleanton-scanner/src/walker.rs +++ b/crates/cleanton-scanner/src/walker.rs @@ -620,13 +620,10 @@ fn should_skip_path( } if options.exclude_external_volumes { - let ext_set = ProtectedPaths::configured_only( - external_volume_roots() - .into_iter() - .filter(|ext| { - !ProtectedPaths::configured_only([ext.clone()]).is_protected_fast(scan_root) - }), - ); + let ext_set = + ProtectedPaths::configured_only(external_volume_roots().into_iter().filter(|ext| { + !ProtectedPaths::configured_only([ext.clone()]).is_protected_fast(scan_root) + })); if ext_set.is_protected_fast(path) { return true; } diff --git a/crates/cleanton-services/src/manager.rs b/crates/cleanton-services/src/manager.rs index 09f7be2..4cb3332 100644 --- a/crates/cleanton-services/src/manager.rs +++ b/crates/cleanton-services/src/manager.rs @@ -382,11 +382,7 @@ impl ServiceManager { ServiceAction::Stop => "stop", ServiceAction::Restart => "restart", ServiceAction::Enable => { - systemd::run_systemctl_user_action( - &service.id, - "enable", - &service.plist_path, - )?; + systemd::run_systemctl_user_action(&service.id, "enable", &service.plist_path)?; "start" } ServiceAction::Disable => { @@ -482,8 +478,7 @@ impl ServiceManager { backup_service_plist(&service.plist_path)?; if service.service_type == ServiceType::SystemdUserService { - let _ = - systemd::run_systemctl_user_action(&service.id, "disable", &service.plist_path); + let _ = systemd::run_systemctl_user_action(&service.id, "disable", &service.plist_path); let _ = systemd::run_systemctl_user_action(&service.id, "stop", &service.plist_path); reviewed_plist_delete::remove_service_plist(&service.plist_path) .map_err(|e| CleantonError::io(&service.plist_path, e))?; @@ -576,8 +571,7 @@ impl ServiceManager { backup_service_plist(&service.plist_path)?; if service.service_type == ServiceType::SystemdUserService { - let _ = - systemd::run_systemctl_user_action(&service.id, "disable", &service.plist_path); + let _ = systemd::run_systemctl_user_action(&service.id, "disable", &service.plist_path); reviewed_plist_delete::remove_service_plist(&service.plist_path) .map_err(|e| CleantonError::io(&service.plist_path, e))?; } else if use_elevation { diff --git a/crates/cleanton-services/src/systemd.rs b/crates/cleanton-services/src/systemd.rs index fa5bba9..7c135f5 100644 --- a/crates/cleanton-services/src/systemd.rs +++ b/crates/cleanton-services/src/systemd.rs @@ -44,11 +44,7 @@ pub fn list_user_services_in_dir( if path.extension().and_then(|ext| ext.to_str()) != Some("service") || !path.is_file() { continue; } - let Some(unit_id) = path - .file_name() - .and_then(|n| n.to_str()) - .map(str::to_owned) - else { + let Some(unit_id) = path.file_name().and_then(|n| n.to_str()).map(str::to_owned) else { continue; }; let Ok(contents) = std::fs::read_to_string(&path) else { @@ -70,23 +66,25 @@ pub fn list_user_services_in_dir( units .into_iter() .map(|(unit_id, path, parsed, user_id, enabled)| { - let status = live_states.get(&unit_id).map_or(ServiceStatus::Stopped, |st| { - match (st.active_state.as_str(), st.main_pid) { - ("active" | "activating" | "reloading", Some(pid)) if pid > 0 => { - let (cpu, mem) = proc_stats - .get(&pid) - .map_or((None, None), |&(c, m)| (Some(c), Some(m))); - ServiceStatus::Running { - pid, - cpu_percent: cpu, - memory_bytes: mem, + let status = live_states + .get(&unit_id) + .map_or(ServiceStatus::Stopped, |st| { + match (st.active_state.as_str(), st.main_pid) { + ("active" | "activating" | "reloading", Some(pid)) if pid > 0 => { + let (cpu, mem) = proc_stats + .get(&pid) + .map_or((None, None), |&(c, m)| (Some(c), Some(m))); + ServiceStatus::Running { + pid, + cpu_percent: cpu, + memory_bytes: mem, + } } + ("active", None) => ServiceStatus::Loaded, + ("failed", _) => ServiceStatus::Error("failed".to_owned()), + _ => ServiceStatus::Stopped, } - ("active", None) => ServiceStatus::Loaded, - ("failed", _) => ServiceStatus::Error("failed".to_owned()), - _ => ServiceStatus::Stopped, - } - }); + }); let short_name = parsed.description.unwrap_or_else(|| { unit_id .strip_suffix(".service") @@ -119,7 +117,10 @@ pub fn run_systemctl_user_action(unit_id: &str, verb: &str, unit_path: &Path) -> Err(CleantonError::Execution { path: unit_path.to_path_buf(), message: if stderr.is_empty() { - format!("systemctl --user {verb} {unit_id} exited with {}", output.status) + format!( + "systemctl --user {verb} {unit_id} exited with {}", + output.status + ) } else { format!("systemctl --user {verb} {unit_id} failed: {stderr}") }, @@ -138,12 +139,7 @@ fn query_systemctl_states(unit_ids: &[&str]) -> HashMap { if unit_ids.is_empty() { return states; } - let mut args = vec![ - "--user", - "show", - "--property=Id,ActiveState,MainPID", - "--", - ]; + let mut args = vec!["--user", "show", "--property=Id,ActiveState,MainPID", "--"]; args.extend_from_slice(unit_ids); let Ok(output) = reviewed_process::output("systemctl", args) else { return states; diff --git a/crates/cleanton-ui/src/screens/dashboard.rs b/crates/cleanton-ui/src/screens/dashboard.rs index d2870a8..6ae6b80 100644 --- a/crates/cleanton-ui/src/screens/dashboard.rs +++ b/crates/cleanton-ui/src/screens/dashboard.rs @@ -241,7 +241,10 @@ pub fn render(f: &mut Frame, app: &mut App, area: Rect) { .fg(app.theme.success) .add_modifier(Modifier::BOLD), ), - Span::styled("Scan Disk ", Style::default().fg(app.theme.fg)), + Span::styled( + "Scan Disk ", + Style::default().fg(app.theme.fg), + ), Span::styled( "[m] ", Style::default() From cef3d97d67eccf69e8a0771e9925e680e531ff83 Mon Sep 17 00:00:00 2001 From: ibnbd <2509519+ibnbd@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:14:23 +0000 Subject: [PATCH 5/8] Fix clippy pedantic lints in recipes matcher and systemd adapter Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- crates/cleanton-antivirus/src/scanner.rs | 133 +++++++++++------------ crates/cleanton-recipes/src/matcher.rs | 25 ++--- crates/cleanton-services/src/systemd.rs | 4 +- 3 files changed, 80 insertions(+), 82 deletions(-) diff --git a/crates/cleanton-antivirus/src/scanner.rs b/crates/cleanton-antivirus/src/scanner.rs index 28755ae..37dd0c3 100644 --- a/crates/cleanton-antivirus/src/scanner.rs +++ b/crates/cleanton-antivirus/src/scanner.rs @@ -133,75 +133,74 @@ impl Scanner { // ordering. Small per-package scans stay inline to avoid thread-spawn // overhead when callers already parallelize across packages. type EngineRunOutput = (EngineStat, Vec); - let results: Vec = - if loaded.files.len() >= 64 && self.engines.len() >= 2 { - let next = std::sync::atomic::AtomicUsize::new(0); - let workers = std::thread::available_parallelism() - .map_or(2, std::num::NonZeroUsize::get) - .clamp(1, 4) - .min(self.engines.len()); - let slots: std::sync::Mutex>> = - std::sync::Mutex::new((0..self.engines.len()).map(|_| None).collect()); - - std::thread::scope(|scope| { - for _ in 0..workers { - scope.spawn(|| { - loop { - let idx = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let Some(engine) = self.engines.get(idx) else { - break; - }; - let ctx = ScanContext { - target_name: target_ref.to_owned(), - files: &loaded.files, - package_json: pkg_ref, - config: &self.config, - }; - let t0 = Instant::now(); - let findings = engine.scan(&ctx); - let stat = EngineStat { - engine: engine.name().to_owned(), - findings: findings.len(), - duration_ms: t0.elapsed().as_millis() as u64, - }; - let mut guard = match slots.lock() { - Ok(g) => g, - Err(p) => p.into_inner(), - }; - if let Some(slot) = guard.get_mut(idx) { - *slot = Some((stat, findings)); - } + let results: Vec = if loaded.files.len() >= 64 && self.engines.len() >= 2 { + let next = std::sync::atomic::AtomicUsize::new(0); + let workers = std::thread::available_parallelism() + .map_or(2, std::num::NonZeroUsize::get) + .clamp(1, 4) + .min(self.engines.len()); + let slots: std::sync::Mutex>> = + std::sync::Mutex::new((0..self.engines.len()).map(|_| None).collect()); + + std::thread::scope(|scope| { + for _ in 0..workers { + scope.spawn(|| { + loop { + let idx = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let Some(engine) = self.engines.get(idx) else { + break; + }; + let ctx = ScanContext { + target_name: target_ref.to_owned(), + files: &loaded.files, + package_json: pkg_ref, + config: &self.config, + }; + let t0 = Instant::now(); + let findings = engine.scan(&ctx); + let stat = EngineStat { + engine: engine.name().to_owned(), + findings: findings.len(), + duration_ms: t0.elapsed().as_millis() as u64, + }; + let mut guard = match slots.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + if let Some(slot) = guard.get_mut(idx) { + *slot = Some((stat, findings)); } - }); - } - }); - - let completed = match slots.into_inner() { - Ok(v) => v, - Err(p) => p.into_inner(), - }; - completed.into_iter().flatten().collect() - } else { - self.engines - .iter() - .map(|engine| { - let ctx = ScanContext { - target_name: target_ref.to_owned(), - files: &loaded.files, - package_json: pkg_ref, - config: &self.config, - }; - let t0 = Instant::now(); - let findings = engine.scan(&ctx); - let stat = EngineStat { - engine: engine.name().to_owned(), - findings: findings.len(), - duration_ms: t0.elapsed().as_millis() as u64, - }; - (stat, findings) - }) - .collect() + } + }); + } + }); + + let completed = match slots.into_inner() { + Ok(v) => v, + Err(p) => p.into_inner(), }; + completed.into_iter().flatten().collect() + } else { + self.engines + .iter() + .map(|engine| { + let ctx = ScanContext { + target_name: target_ref.to_owned(), + files: &loaded.files, + package_json: pkg_ref, + config: &self.config, + }; + let t0 = Instant::now(); + let findings = engine.scan(&ctx); + let stat = EngineStat { + engine: engine.name().to_owned(), + findings: findings.len(), + duration_ms: t0.elapsed().as_millis() as u64, + }; + (stat, findings) + }) + .collect() + }; let mut all_findings = Vec::new(); let mut engine_stats = Vec::with_capacity(results.len()); diff --git a/crates/cleanton-recipes/src/matcher.rs b/crates/cleanton-recipes/src/matcher.rs index c34014b..8da7d20 100644 --- a/crates/cleanton-recipes/src/matcher.rs +++ b/crates/cleanton-recipes/src/matcher.rs @@ -534,14 +534,15 @@ fn is_sensitive_basename(path: &Path) -> bool { enum CompiledNameMatcher { Exact(String), Glob(Pattern), + Invalid, } impl CompiledNameMatcher { - fn compile(raw: &str) -> Option { - if !contains_glob_meta(raw) { - Some(Self::Exact(raw.to_owned())) + fn compile(raw: &str) -> Self { + if contains_glob_meta(raw) { + Pattern::new(raw).map_or(Self::Invalid, Self::Glob) } else { - Pattern::new(raw).ok().map(Self::Glob) + Self::Exact(raw.to_owned()) } } @@ -549,6 +550,7 @@ impl CompiledNameMatcher { match self { Self::Exact(expected) => name == expected, Self::Glob(pattern) => pattern.matches(name), + Self::Invalid => false, } } } @@ -665,7 +667,7 @@ struct CompiledSpec { exclude_names: Vec, exclude_paths: Vec>, directory_name: Option, - file_name: Option>, + file_name: Option, file_extensions: Option>, path_glob: Option, parent_must_contain: Option>, @@ -725,18 +727,15 @@ impl CompiledSpec { entry_ext: Option<&str>, dir_contains: &impl Fn(&Path, &str) -> bool, ) -> bool { - if let Some(expected) = &self.directory_name { - if entry.kind != FileKind::Directory || entry_name != Some(expected.as_str()) { - return false; - } + if let Some(expected) = &self.directory_name + && (entry.kind != FileKind::Directory || entry_name != Some(expected.as_str())) + { + return false; } - if let Some(compiled_name) = &self.file_name { + if let Some(matcher) = &self.file_name { if entry.kind != FileKind::File { return false; } - let Some(matcher) = compiled_name.as_ref() else { - return false; - }; let Some(actual) = entry_name else { return false; }; diff --git a/crates/cleanton-services/src/systemd.rs b/crates/cleanton-services/src/systemd.rs index 7c135f5..cc370b6 100644 --- a/crates/cleanton-services/src/systemd.rs +++ b/crates/cleanton-services/src/systemd.rs @@ -164,9 +164,9 @@ fn parse_systemctl_show_output(stdout: &str, out: &mut HashMap() && pid > 0 From 401099e4db39f2fb5d007db050dc1966c3876a86 Mon Sep 17 00:00:00 2001 From: ibnbd <2509519+ibnbd@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:18:32 +0000 Subject: [PATCH 6/8] Fix remaining type and field alignments in engine and Slint app Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- crates/cleanton-app/src/main.rs | 97 +++++++++--------------- crates/cleanton-engine/src/analyzer.rs | 25 ++++-- crates/cleanton-engine/src/background.rs | 4 + crates/cleanton-engine/src/security.rs | 3 +- 4 files changed, 57 insertions(+), 72 deletions(-) diff --git a/crates/cleanton-app/src/main.rs b/crates/cleanton-app/src/main.rs index b910080..f4acb29 100644 --- a/crates/cleanton-app/src/main.rs +++ b/crates/cleanton-app/src/main.rs @@ -1726,24 +1726,24 @@ fn spawn_resource_pressure_watcher(ui_weak: slint::Weak, shared_confi } fn read_resource_status(config: &CleantonConfig) -> ResourceStatus { - snapshot_resource_status( - config.background.disk_pressure_percent, - config.background.memory_pressure_percent, - ) + let (disk_pct, mem_pct) = if config.background.enabled { + ( + config.background.disk_pressure_percent, + config.background.memory_pressure_percent, + ) + } else { + (0, 0) + }; + snapshot_resource_status(disk_pct, mem_pct) } fn apply_resource_status(state: &AppState, status: &ResourceStatus) { - if let (Some(total), Some(available), Some(pct)) = ( - status.memory_total_bytes, - status.memory_available_bytes, - status.memory_used_percent, - ) { + if status.memory_total_bytes > 0 { + let total = status.memory_total_bytes; + let available = status.memory_available_bytes; + let pct = status.memory_used_percent; let used = total.saturating_sub(available); - let ratio = if total > 0 { - (used as f64 / total as f64) as f32 - } else { - 0.0 - }; + let ratio = (used as f64 / total as f64) as f32; state.set_memory_ready(true); state.set_memory_total(display::human_bytes(total).into()); state.set_memory_used(format!("{} ({pct}%)", display::human_bytes(used)).into()); @@ -1765,24 +1765,20 @@ fn apply_resource_status(state: &AppState, status: &ResourceStatus) { MEMORY_OFFER_DISMISSED.store(false, Ordering::Relaxed); } - if let Some(offer) = &status.disk_offer + if let Some((advice, offer)) = &status.disk_offer && !DISK_OFFER_DISMISSED.load(Ordering::Relaxed) { - let pct = status.disk_used_percent.unwrap_or(0); - let avail = status - .disk_available_bytes - .map(display::human_bytes) - .unwrap_or_else(|| "--".to_owned()); + let pct = status.disk_used_percent; + let avail = display::human_bytes(status.disk_available_bytes); state.set_disk_offer_headline( format!("Disk usage is high ({pct}% used, {avail} free) - disk cleanup recommended") .into(), ); let detail = match offer { CleanupOffer::Disk { - reclaimable_bytes, + estimated_reclaimable_bytes, candidates, - recommended_action, - } if *reclaimable_bytes > 0 => { + } if *estimated_reclaimable_bytes > 0 => { let top = candidates .iter() .take(3) @@ -1790,16 +1786,11 @@ fn apply_resource_status(state: &AppState, status: &ResourceStatus) { .collect::>() .join(", "); format!( - "Detected ~{} in quick-relief targets: {}. {}", - display::human_bytes(*reclaimable_bytes), - top, - recommended_action + "Detected ~{} in quick-relief targets ({top}). Run Scan & Review to move safe caches and build artifacts to the Trash.", + display::human_bytes(*estimated_reclaimable_bytes), ) } - CleanupOffer::Disk { - recommended_action, .. - } => recommended_action.clone(), - CleanupOffer::Memory { .. } => String::new(), + _ => advice.clone(), }; state.set_disk_offer_detail(detail.into()); state.set_disk_offer_active(true); @@ -1807,14 +1798,11 @@ fn apply_resource_status(state: &AppState, status: &ResourceStatus) { state.set_disk_offer_active(false); } - if let Some(offer) = &status.memory_offer + if let Some((advice, offer)) = &status.memory_offer && !MEMORY_OFFER_DISMISSED.load(Ordering::Relaxed) { - let pct = status.memory_used_percent.unwrap_or(0); - let avail = status - .memory_available_bytes - .map(display::human_bytes) - .unwrap_or_else(|| "--".to_owned()); + let pct = status.memory_used_percent; + let avail = display::human_bytes(status.memory_available_bytes); state.set_memory_offer_headline( format!( "Memory usage is high ({pct}% used, {avail} available) - memory cleanup recommended" @@ -1822,23 +1810,18 @@ fn apply_resource_status(state: &AppState, status: &ResourceStatus) { .into(), ); let detail = match offer { - CleanupOffer::Memory { - top_processes, - recommended_action, - .. - } if !top_processes.is_empty() => { + CleanupOffer::Memory { top_processes, .. } if !top_processes.is_empty() => { let top = top_processes .iter() .take(4) .map(|p| format!("{} ({})", p.name, display::human_bytes(p.rss_bytes))) .collect::>() .join(", "); - format!("Top resident processes: {top}. {recommended_action}") + format!( + "Top resident processes: {top}. Reclaim idle allocator pages or review running background services." + ) } - CleanupOffer::Memory { - recommended_action, .. - } => recommended_action.clone(), - CleanupOffer::Disk { .. } => String::new(), + _ => advice.clone(), }; state.set_memory_offer_detail(detail.into()); state.set_memory_offer_active(true); @@ -2656,12 +2639,8 @@ fn populate_settings(state: &AppState, config: &CleantonConfig) { state.set_setting_max_action_gb( i32::try_from(config.safety.max_single_action_gb).unwrap_or(i32::MAX), ); - state.set_setting_disk_pressure_percent(i32::from( - config.background.disk_pressure_percent.unwrap_or(0), - )); - state.set_setting_memory_pressure_percent(i32::from( - config.background.memory_pressure_percent.unwrap_or(0), - )); + state.set_setting_disk_pressure_percent(i32::from(config.background.disk_pressure_percent)); + state.set_setting_memory_pressure_percent(i32::from(config.background.memory_pressure_percent)); } /// Read the edited Settings controls back into `config`. @@ -2685,17 +2664,9 @@ fn read_settings_into_config(state: &AppState, config: &mut CleantonConfig) { config.safety.max_single_action_gb = u64::try_from(state.get_setting_max_action_gb().max(1)) .unwrap_or(config.safety.max_single_action_gb); let disk_pct = state.get_setting_disk_pressure_percent().clamp(0, 99); - config.background.disk_pressure_percent = if disk_pct == 0 { - None - } else { - u8::try_from(disk_pct).ok() - }; + config.background.disk_pressure_percent = u8::try_from(disk_pct).unwrap_or(0); let mem_pct = state.get_setting_memory_pressure_percent().clamp(0, 99); - config.background.memory_pressure_percent = if mem_pct == 0 { - None - } else { - u8::try_from(mem_pct).ok() - }; + config.background.memory_pressure_percent = u8::try_from(mem_pct).unwrap_or(0); } fn clamp_to_i32(value: usize) -> i32 { diff --git a/crates/cleanton-engine/src/analyzer.rs b/crates/cleanton-engine/src/analyzer.rs index c15ac1d..1244f4e 100644 --- a/crates/cleanton-engine/src/analyzer.rs +++ b/crates/cleanton-engine/src/analyzer.rs @@ -153,7 +153,7 @@ fn match_recipes(scan: &ScanResult, recipes: &RecipeSet) -> Vec { let suppressed = suppressed_roots(scan, &compiled, &newest, &markers); let mut candidates_buf = Vec::with_capacity(16); - let mut matched: Vec<(PlannedAction, bool)> = scan + let mut matched: Vec<(CleanupAction, bool)> = scan .entries .iter() .filter_map(|entry| { @@ -169,13 +169,17 @@ fn match_recipes(scan: &ScanResult, recipes: &RecipeSet) -> Vec { |dir, marker| markers.contains(dir, marker), |dir| newest.get(dir).copied(), ) - .map(|action| (action, entry.is_dir && entry.size == 0)) + .map(|action| { + let needs_size = + entry.kind == FileKind::Directory && entry.allocated_bytes == 0; + (action, needs_size) + }) }) .collect(); // When a matched directory sat right at the walker's `max_depth` ceiling, - // its children were not walked so `entry.size` is 0. Backfill those via - // targeted sizing so reclaimable-byte totals remain accurate. + // its children were not walked so `entry.allocated_bytes` is 0. Backfill + // those via targeted sizing so reclaimable-byte totals remain accurate. let unmeasured: Vec = matched .iter() .filter(|(_, needs_size)| *needs_size) @@ -183,15 +187,20 @@ fn match_recipes(scan: &ScanResult, recipes: &RecipeSet) -> Vec { .map(|(action, _)| action.target.clone()) .collect(); if !unmeasured.is_empty() { - let sized: HashMap = cleanton_scanner::size_targets(&unmeasured) + let sized: HashMap = cleanton_scanner::size_targets(&unmeasured) + .targets .into_iter() + .map(|ts| (ts.path, (ts.logical_bytes, ts.allocated_bytes))) .collect(); for (action, needs_size) in &mut matched { if *needs_size - && let Some(&bytes) = sized.get(&action.target) - && bytes > 0 + && let Some(&(logical, allocated)) = sized.get(&action.target) + && (logical > 0 || allocated > 0) { - action.size = bytes; + action.logical_bytes = logical; + action.reclaimable_bytes = allocated; + action.fingerprint.logical_bytes = logical; + action.fingerprint.allocated_bytes = allocated; } } } diff --git a/crates/cleanton-engine/src/background.rs b/crates/cleanton-engine/src/background.rs index df67b99..6f00527 100644 --- a/crates/cleanton-engine/src/background.rs +++ b/crates/cleanton-engine/src/background.rs @@ -981,8 +981,12 @@ mod tests { let cap = VolumeCapacity { total_bytes: 100 * 1024 * 1024 * 1024, + free_bytes: 10 * 1024 * 1024 * 1024, available_bytes: 10 * 1024 * 1024 * 1024, + available_for_important_usage_bytes: 10 * 1024 * 1024 * 1024, used_bytes: 90 * 1024 * 1024 * 1024, + purgeable_estimate_bytes: 0, + block_size: 4096, }; let (advice, offer) = build_disk_offer(90, &cap, temp.path()); assert!( diff --git a/crates/cleanton-engine/src/security.rs b/crates/cleanton-engine/src/security.rs index da265ee..438571b 100644 --- a/crates/cleanton-engine/src/security.rs +++ b/crates/cleanton-engine/src/security.rs @@ -1813,7 +1813,8 @@ fn enumerate_python_packages(target: &SecurityTarget, out: &mut Vec-.dist-info` directories once per `site-packages` // root instead of re-reading the directory for every package. - let mut dist_versions: HashMap = HashMap::new(); + let mut dist_versions: std::collections::HashMap = + std::collections::HashMap::new(); for path in &paths { if !path.is_dir() { continue; From 10fda13409fa50a8213afa7cde427d2d7db316bf Mon Sep 17 00:00:00 2001 From: ibnbd <2509519+ibnbd@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:21:16 +0000 Subject: [PATCH 7/8] Use write! macro instead of format! push_str in background helper Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- crates/cleanton-engine/src/background.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/cleanton-engine/src/background.rs b/crates/cleanton-engine/src/background.rs index 6f00527..068b806 100644 --- a/crates/cleanton-engine/src/background.rs +++ b/crates/cleanton-engine/src/background.rs @@ -592,15 +592,18 @@ fn build_disk_offer( then empty the Trash to reclaim freed space.", ); } else { + use std::fmt::Write as _; + let named: Vec = candidates .iter() .map(|c| format!("{} ({})", c.label, human_bytes(c.bytes))) .collect(); - advice.push_str(&format!( + let _ = write!( + advice, " At least {} is reclaimable across {}. Run Disk Cleanup to review and move caches to Trash, or empty the Trash.", human_bytes(estimated_reclaimable_bytes), named.join(", ") - )); + ); } ( From 286b4d108951303c3d3dd0153e6f59e0d7a575ee Mon Sep 17 00:00:00 2001 From: ibnbd <2509519+ibnbd@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:26:49 +0000 Subject: [PATCH 8/8] Restore dev.yml workflow after full macOS and Ubuntu verification Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/dev.yml | 47 +++++++++------------------------------ 1 file changed, 10 insertions(+), 37 deletions(-) diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 49fe8bd..c68a80b 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -66,6 +66,7 @@ jobs: rust: name: Rust gates (${{ matrix.os }}) + if: github.event_name == 'workflow_dispatch' runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -98,45 +99,17 @@ jobs: echo "pinned=$pinned actual=$actual" [ "$pinned" = "$actual" ] - - name: Check formatting, Clippy, and Tests (emit annotations) + - name: Check formatting + run: cargo fmt --all --check + + - name: Clippy (deny warnings) + run: cargo clippy --workspace --all-targets --all-features -- -D warnings + + - name: Test (tee full log) shell: bash run: | - python3 - <<'PY' - import base64, subprocess, sys - - def emit(kind, title, text): - b64 = base64.b64encode(text.encode("utf-8", errors="replace")).decode("ascii") - chunk_size = 3000 - for idx in range(0, min(len(b64), chunk_size * 15), chunk_size): - part = b64[idx:idx + chunk_size] - print(f"::{kind} title={title}_{idx // chunk_size:02d}::{part}", flush=True) - - subprocess.run(["cargo", "fmt", "--all"], check=False) - fmt_diff = subprocess.run(["git", "diff"], capture_output=True, text=True).stdout - if fmt_diff: - emit("warning", "FMTDIFF", fmt_diff) - - clippy = subprocess.run( - ["cargo", "clippy", "--workspace", "--all-targets", "--all-features", "--keep-going", "--message-format=short", "--", "-D", "warnings"], - capture_output=True, text=True - ) - if clippy.returncode != 0: - emit("error", "CLIPPY", clippy.stdout + "\n" + clippy.stderr) - sys.exit(clippy.returncode) - - test = subprocess.run( - ["cargo", "test", "--workspace", "--all-features", "--", "--nocapture"], - capture_output=True, text=True - ) - if test.returncode != 0: - out = (test.stdout + "\n" + test.stderr)[-35000:] - emit("error", "TESTS", out) - sys.exit(test.returncode) - - if fmt_diff: - sys.exit(1) - print("::notice title=ALL_GREEN::fmt+clippy+testsclean", flush=True) - PY + set -o pipefail + cargo test --workspace --all-features 2>&1 | tee test-output.txt - name: Upload test output if: always()