use std::num::NonZeroUsize;
#[cfg(any(target_os = "linux", target_os = "macos"))]
use std::path::Path;
use anyhow::Result;
use rebecca::core::RuleDefinition;
use rebecca::core::applications::ApplicationDiscovery;
#[cfg(debug_assertions)]
use rebecca::core::applications::{
InstalledApplication, NoopApplicationDiscovery, StaticApplicationDiscovery, SteamInstallation,
};
use rebecca::core::config::{AppPaths, load_app_paths};
use rebecca::core::history::HistoryStore;
use rebecca::core::plan::{CleanupIssueSummary, CleanupTarget, CleanupTargetIssueReason};
use rebecca::core::warnings::ACTIVE_PROCESS_WARNING;
use serde::Serialize;
use crate::cli::OutputMode;
use crate::history_view::{HistoryAggregateSummary, HistoryProjection, HistoryRunHighlight};
use crate::output::{format_bytes, format_issue_matrix_entry, restore_hint_suffix};
#[derive(Debug, Serialize)]
struct PermissionDiagnostic<'a> {
platform: &'a str,
platform_supported: bool,
cleanup_execution_supported: bool,
privilege_level: &'a str,
suggested_action: &'a str,
#[cfg(target_os = "macos")]
macos_privacy: MacosPrivacyDiagnostic,
}
#[cfg(target_os = "macos")]
#[derive(Debug, Clone, Serialize)]
struct MacosPrivacyDiagnostic {
status: MacosPrivacyStatus,
probes: Vec<MacosPrivacyProbe>,
action_kind: MacosPrivacyActionKind,
full_disk_access_relevant: bool,
affected_cleanup_families: Vec<&'static str>,
suggested_action: &'static str,
}
#[cfg(target_os = "macos")]
#[derive(Debug, Clone, Serialize)]
struct MacosPrivacyProbe {
label: &'static str,
path: String,
status: MacosPrivacyProbeStatus,
}
#[cfg(target_os = "macos")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
enum MacosPrivacyStatus {
LikelyBlocked,
NoBlockDetected,
NotProbed,
Unknown,
}
#[cfg(target_os = "macos")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
enum MacosPrivacyActionKind {
GrantFullDiskAccessIfNeeded,
ContinuePreviewFirst,
ReviewDryRun,
NoAction,
}
#[cfg(target_os = "macos")]
impl MacosPrivacyActionKind {
fn as_str(self) -> &'static str {
match self {
Self::GrantFullDiskAccessIfNeeded => "grant-full-disk-access-if-needed",
Self::ContinuePreviewFirst => "continue-preview-first",
Self::ReviewDryRun => "review-dry-run",
Self::NoAction => "no-action",
}
}
}
#[cfg(target_os = "macos")]
impl MacosPrivacyStatus {
fn as_str(self) -> &'static str {
match self {
Self::LikelyBlocked => "likely-blocked",
Self::NoBlockDetected => "no-block-detected",
Self::NotProbed => "not-probed",
Self::Unknown => "unknown",
}
}
}
#[cfg(target_os = "macos")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
enum MacosPrivacyProbeStatus {
Readable,
PermissionDenied,
Missing,
Unknown,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct ActiveProcessDiagnostic {
platform: String,
platform_supported: bool,
process_inspection_available: bool,
matches: Vec<ActiveProcessMatch>,
unavailable_reason: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
struct ActiveProcessMatch {
process_id: u32,
executable_name: String,
warning: String,
rule_ids: Vec<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct ProcessSnapshot {
pub(crate) process_id: u32,
pub(crate) executable_name: String,
}
fn config_paths_json(paths: &AppPaths) -> serde_json::Value {
serde_json::json!({
"config_dir": &paths.config_dir,
"config_file": &paths.config_file,
"state_dir": &paths.state_dir,
"cache_dir": &paths.cache_dir,
"history_file": &paths.history_file,
"storage": paths.storage_entries(),
})
}
pub fn print_history(output_mode: OutputMode, limit: Option<NonZeroUsize>) -> Result<()> {
let paths = load_app_paths()?;
let store = HistoryStore::new(paths.history_file);
let report = match limit {
Some(limit) => store.load_tail_report(limit)?,
None => store.load_report()?,
};
for diagnostic in &report.diagnostics {
eprintln!(
"History warning: skipped corrupted history line {} ({})",
diagnostic.line_number, diagnostic.message
);
}
let history = HistoryProjection::new(report.entries, limit);
crate::output::print_command_success(
"history",
"history-list",
output_mode,
|| history.entries(),
|| print_history_human(&history),
)
}
fn print_history_human(history: &HistoryProjection) -> Result<()> {
if history.is_empty() {
println!("No cleanup history found.");
return Ok(());
}
println!("Cleanup history: {} run(s)", history.entries().len());
print_history_summary(history.summary());
print_largest_history_runs(history.largest_runs());
for entry in history.entries() {
println!(
"- {}: {} completed, {} failed, {} pending bytes{}",
entry.recorded_at_unix_seconds,
entry.summary.completed_targets,
entry.summary.failed_targets,
entry.summary.pending_reclaim_bytes,
restore_hint_suffix(
entry
.targets
.iter()
.filter_map(|target| target.restore_hint.as_deref())
)
);
print_history_issue_matrix(&entry.summary.issue_matrix);
print_history_issue_targets(&entry.targets);
}
Ok(())
}
fn print_history_summary(summary: &HistoryAggregateSummary) {
println!();
println!("History summary:");
println!(" Runs: {}", summary.runs);
println!(" Completed targets: {}", summary.completed_targets);
println!(" Skipped targets: {}", summary.skipped_targets);
println!(" Blocked targets: {}", summary.blocked_targets);
println!(" Failed targets: {}", summary.failed_targets);
println!(
" Freed bytes: {} ({})",
summary.freed_bytes,
format_bytes(summary.freed_bytes)
);
println!(
" Pending reclaim bytes: {} ({})",
summary.pending_reclaim_bytes,
format_bytes(summary.pending_reclaim_bytes)
);
println!();
}
fn print_largest_history_runs(runs: &[HistoryRunHighlight]) {
if runs.is_empty() {
return;
}
println!("Largest cleanup runs:");
for run in runs {
println!(
" - {}: {} ({}) total, {} ({}) freed, {} ({}) pending reclaim",
run.recorded_at_unix_seconds,
run.total_bytes,
format_bytes(run.total_bytes),
run.freed_bytes,
format_bytes(run.freed_bytes),
run.pending_reclaim_bytes,
format_bytes(run.pending_reclaim_bytes)
);
}
println!();
}
fn print_history_issue_matrix(issue_matrix: &[CleanupIssueSummary]) {
if issue_matrix.is_empty() {
return;
}
println!(" Issue matrix:");
for issue in issue_matrix {
println!(" - {}", format_issue_matrix_entry(issue));
}
}
fn print_history_issue_targets(targets: &[CleanupTarget]) {
let issue_targets = targets
.iter()
.filter(|target| target.status.is_issue())
.collect::<Vec<_>>();
if issue_targets.is_empty() {
return;
}
println!(" Issue targets:");
for target in issue_targets {
println!(
" - {} {}: {} [{}]{}{}",
target.status.label(),
target
.reason_code
.unwrap_or(CleanupTargetIssueReason::Unclassified)
.label(),
target.rule_id,
target.path.display(),
target
.reason
.as_deref()
.map(|reason| format!(" ({reason})"))
.unwrap_or_default(),
restore_hint_suffix(target.restore_hint.as_deref())
);
}
}
pub fn print_config_paths(output_mode: OutputMode) -> Result<()> {
let paths = load_app_paths()?;
crate::output::print_command_success(
"config paths",
"config-paths",
output_mode,
|| config_paths_json(&paths),
|| {
for entry in paths.storage_entries() {
println!(
"{}: {} [{}; {}]",
entry.id.label(),
entry.path.display(),
entry.lifecycle.label(),
entry.retention.label()
);
}
Ok(())
},
)
}
pub fn print_privilege_level(output_mode: OutputMode) -> Result<()> {
crate::output::print_command_success(
"doctor permissions",
"permissions-diagnostic",
output_mode,
permission_diagnostic,
|| {
let diagnostic = permission_diagnostic();
println!("Privilege level: {}", diagnostic.privilege_level);
println!("Suggested action: {}", diagnostic.suggested_action);
#[cfg(target_os = "macos")]
{
let macos_privacy = &diagnostic.macos_privacy;
println!("macOS privacy: {}", macos_privacy.status.as_str());
println!(
"macOS privacy action kind: {}",
macos_privacy.action_kind.as_str()
);
println!("macOS privacy action: {}", macos_privacy.suggested_action);
}
Ok(())
},
)
}
pub fn print_active_processes(output_mode: OutputMode) -> Result<()> {
crate::output::print_command_success(
"doctor active-processes",
"active-process-diagnostic",
output_mode,
active_process_diagnostic,
|| {
let diagnostic = active_process_diagnostic();
if !diagnostic.process_inspection_available {
println!(
"Active process diagnostics unavailable: {}",
diagnostic
.unavailable_reason
.as_deref()
.unwrap_or("process inspection is unavailable")
);
return Ok(());
}
if diagnostic.matches.is_empty() {
println!("Active process diagnostics: no warning-bearing cleanup rules matched.");
return Ok(());
}
println!("Active process diagnostics:");
for matched in &diagnostic.matches {
println!(
"- {} (pid {}): {} [{}]",
matched.executable_name,
matched.process_id,
matched.warning,
matched.rule_ids.join(", ")
);
}
Ok(())
},
)
}
fn active_process_diagnostic() -> ActiveProcessDiagnostic {
let rules = match rebecca::rules::builtin_rules() {
Ok(rules) => rules,
Err(err) => {
return ActiveProcessDiagnostic {
platform: current_platform_label().to_string(),
platform_supported: active_process_platform_supported(),
process_inspection_available: false,
matches: Vec::new(),
unavailable_reason: Some(err.to_string()),
};
}
};
match active_process_snapshots() {
Ok(processes) => active_process_diagnostic_from_processes(&rules, processes),
Err(err) => ActiveProcessDiagnostic {
platform: current_platform_label().to_string(),
platform_supported: active_process_platform_supported(),
process_inspection_available: false,
matches: Vec::new(),
unavailable_reason: Some(err.to_string()),
},
}
}
pub(crate) fn active_process_diagnostic_from_processes(
rules: &[RuleDefinition],
processes: Vec<ProcessSnapshot>,
) -> ActiveProcessDiagnostic {
let active_rules = rules
.iter()
.filter(|rule| rule.platform == rebecca::core::Platform::current())
.filter(|rule| {
rule.warnings
.iter()
.any(|warning| warning.eq_ignore_ascii_case(ACTIVE_PROCESS_WARNING))
})
.collect::<Vec<_>>();
let mut matches = Vec::new();
for process in processes {
let process_key = normalized_process_token(&process.executable_name);
if process_key.is_empty() {
continue;
}
let rule_ids = active_rules
.iter()
.filter(|rule| cleanup_rule_process_tokens(rule).contains(&process_key))
.map(|rule| rule.id.clone())
.collect::<Vec<_>>();
if rule_ids.is_empty() {
continue;
}
matches.push(ActiveProcessMatch {
process_id: process.process_id,
executable_name: process.executable_name,
warning: ACTIVE_PROCESS_WARNING.to_string(),
rule_ids,
});
}
matches.sort_by(|left, right| {
left.executable_name
.to_ascii_lowercase()
.cmp(&right.executable_name.to_ascii_lowercase())
.then_with(|| left.process_id.cmp(&right.process_id))
});
ActiveProcessDiagnostic {
platform: current_platform_label().to_string(),
platform_supported: active_process_platform_supported(),
process_inspection_available: true,
matches,
unavailable_reason: None,
}
}
fn permission_diagnostic() -> PermissionDiagnostic<'static> {
let privilege_level = current_privilege_label();
PermissionDiagnostic {
platform: current_platform_label(),
platform_supported: cleanup_platform_supported(),
cleanup_execution_supported: cleanup_platform_supported(),
privilege_level,
suggested_action: permission_suggested_action(privilege_level),
#[cfg(target_os = "macos")]
macos_privacy: macos_privacy_diagnostic(),
}
}
fn permission_suggested_action(privilege_level: &str) -> &'static str {
match privilege_level {
"elevated" if cfg!(target_os = "linux") => {
"cleanup execution can use elevated Linux permissions"
}
"elevated" if cfg!(target_os = "macos") => macos_permission_suggested_action(),
"elevated" => "cleanup execution can use elevated Windows permissions",
"standard-user" if cfg!(target_os = "linux") => {
"use sudo only for reviewed permission-sensitive system cache rules"
}
"standard-user" if cfg!(target_os = "macos") => macos_permission_suggested_action(),
"standard-user" => "run from an elevated shell if protected cleanup targets are blocked",
"unsupported-platform" => {
"cleanup execution is not supported on this platform; use preview commands only"
}
_ => {
"permission level could not be determined; use dry-run preview before executing cleanup"
}
}
}
fn macos_permission_suggested_action() -> &'static str {
"avoid sudo for macOS cleanup; preview as the current user and grant Full Disk Access to the terminal only when privacy controls block reviewed user-owned cache paths"
}
#[cfg(target_os = "macos")]
fn macos_privacy_diagnostic() -> MacosPrivacyDiagnostic {
let home = std::env::var_os("HOME").map(std::path::PathBuf::from);
macos_privacy_diagnostic_from_home(home.as_deref())
}
#[cfg(target_os = "macos")]
fn macos_privacy_diagnostic_from_home(home: Option<&Path>) -> MacosPrivacyDiagnostic {
let Some(home) = home else {
return MacosPrivacyDiagnostic {
status: MacosPrivacyStatus::NotProbed,
probes: Vec::new(),
action_kind: macos_privacy_action_kind(MacosPrivacyStatus::NotProbed),
full_disk_access_relevant: false,
affected_cleanup_families: Vec::new(),
suggested_action: macos_privacy_suggested_action(MacosPrivacyStatus::NotProbed),
};
};
let probes = [
("mail", home.join("Library").join("Mail")),
("messages", home.join("Library").join("Messages")),
("safari", home.join("Library").join("Safari")),
]
.into_iter()
.map(|(label, path)| MacosPrivacyProbe {
label,
path: path.display().to_string(),
status: macos_privacy_probe_status(&path),
})
.collect::<Vec<_>>();
macos_privacy_diagnostic_from_probes(probes)
}
#[cfg(target_os = "macos")]
fn macos_privacy_diagnostic_from_probes(probes: Vec<MacosPrivacyProbe>) -> MacosPrivacyDiagnostic {
let status = macos_privacy_status_from_probes(&probes);
let full_disk_access_relevant = status == MacosPrivacyStatus::LikelyBlocked;
MacosPrivacyDiagnostic {
status,
probes,
action_kind: macos_privacy_action_kind(status),
full_disk_access_relevant,
affected_cleanup_families: macos_privacy_affected_cleanup_families(status),
suggested_action: macos_privacy_suggested_action(status),
}
}
#[cfg(target_os = "macos")]
fn macos_privacy_probe_status(path: &Path) -> MacosPrivacyProbeStatus {
match std::fs::read_dir(path) {
Ok(_) => MacosPrivacyProbeStatus::Readable,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => MacosPrivacyProbeStatus::Missing,
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
MacosPrivacyProbeStatus::PermissionDenied
}
Err(_) => MacosPrivacyProbeStatus::Unknown,
}
}
#[cfg(target_os = "macos")]
fn macos_privacy_status_from_probes(probes: &[MacosPrivacyProbe]) -> MacosPrivacyStatus {
if probes.is_empty() {
return MacosPrivacyStatus::NotProbed;
}
if probes
.iter()
.any(|probe| probe.status == MacosPrivacyProbeStatus::PermissionDenied)
{
return MacosPrivacyStatus::LikelyBlocked;
}
if probes
.iter()
.any(|probe| probe.status == MacosPrivacyProbeStatus::Readable)
{
return MacosPrivacyStatus::NoBlockDetected;
}
if probes
.iter()
.all(|probe| probe.status == MacosPrivacyProbeStatus::Missing)
{
return MacosPrivacyStatus::NotProbed;
}
MacosPrivacyStatus::Unknown
}
#[cfg(target_os = "macos")]
fn macos_privacy_suggested_action(status: MacosPrivacyStatus) -> &'static str {
match status {
MacosPrivacyStatus::LikelyBlocked => {
"grant Full Disk Access to the terminal only if the blocked paths are reviewed user-owned cache paths; do not retry with sudo"
}
MacosPrivacyStatus::NoBlockDetected => {
"no macOS privacy block was detected by the read-only probes; keep cleanup preview-first as the current user"
}
MacosPrivacyStatus::NotProbed => {
"macOS privacy probes did not find local protected data to read; keep cleanup preview-first as the current user"
}
MacosPrivacyStatus::Unknown => {
"macOS privacy probe results were inconclusive; use dry-run preview and grant Full Disk Access only for reviewed user-owned cache paths"
}
}
}
#[cfg(target_os = "macos")]
fn macos_privacy_action_kind(status: MacosPrivacyStatus) -> MacosPrivacyActionKind {
match status {
MacosPrivacyStatus::LikelyBlocked => MacosPrivacyActionKind::GrantFullDiskAccessIfNeeded,
MacosPrivacyStatus::NoBlockDetected => MacosPrivacyActionKind::ContinuePreviewFirst,
MacosPrivacyStatus::NotProbed => MacosPrivacyActionKind::NoAction,
MacosPrivacyStatus::Unknown => MacosPrivacyActionKind::ReviewDryRun,
}
}
#[cfg(target_os = "macos")]
fn macos_privacy_affected_cleanup_families(status: MacosPrivacyStatus) -> Vec<&'static str> {
match status {
MacosPrivacyStatus::LikelyBlocked | MacosPrivacyStatus::Unknown => {
vec!["mail", "messages", "safari", "user-library-cache"]
}
MacosPrivacyStatus::NoBlockDetected | MacosPrivacyStatus::NotProbed => Vec::new(),
}
}
fn cleanup_platform_supported() -> bool {
cfg!(windows) || cfg!(target_os = "linux") || cfg!(target_os = "macos")
}
fn active_process_platform_supported() -> bool {
cfg!(windows) || cfg!(target_os = "linux") || cfg!(target_os = "macos")
}
fn current_platform_label() -> &'static str {
if cfg!(windows) {
"windows"
} else if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "linux") {
"linux"
} else {
"unsupported"
}
}
fn active_process_snapshots() -> Result<Vec<ProcessSnapshot>> {
if let Some(processes) = active_process_snapshots_override() {
return Ok(processes);
}
#[cfg(windows)]
{
rebecca::windows::process::active_processes()
.map(|processes| {
processes
.into_iter()
.map(|process| ProcessSnapshot {
process_id: process.process_id,
executable_name: process.executable_name,
})
.collect()
})
.map_err(Into::into)
}
#[cfg(target_os = "linux")]
{
linux_active_processes()
}
#[cfg(target_os = "macos")]
{
macos_active_processes()
}
#[cfg(all(not(windows), not(target_os = "linux"), not(target_os = "macos")))]
{
Err(rebecca::core::RebeccaError::PlatformUnavailable(
"process diagnostics are not available on this platform".to_string(),
)
.into())
}
}
#[cfg(target_os = "linux")]
fn linux_active_processes() -> Result<Vec<ProcessSnapshot>> {
linux_active_processes_from_proc_root(Path::new("/proc"))
}
#[cfg(target_os = "linux")]
fn linux_active_processes_from_proc_root(proc_root: &Path) -> Result<Vec<ProcessSnapshot>> {
let entries = std::fs::read_dir(proc_root).map_err(|err| {
rebecca::core::RebeccaError::PlatformUnavailable(format!(
"Linux /proc process diagnostics are unavailable: {err}"
))
})?;
let mut processes = Vec::new();
for entry in entries {
let Ok(entry) = entry else {
continue;
};
let file_name = entry.file_name();
let Some(pid_text) = file_name.to_str() else {
continue;
};
let Ok(process_id) = pid_text.parse::<u32>() else {
continue;
};
let process_dir = entry.path();
let Some(executable_name) = linux_process_name(&process_dir) else {
continue;
};
processes.push(ProcessSnapshot {
process_id,
executable_name,
});
}
Ok(processes)
}
#[cfg(target_os = "linux")]
fn linux_process_name(process_dir: &Path) -> Option<String> {
let comm = std::fs::read_to_string(process_dir.join("comm"))
.ok()
.map(|name| name.trim().to_string())
.filter(|name| !name.is_empty());
if comm.is_some() {
return comm;
}
std::fs::read_link(process_dir.join("exe"))
.ok()
.and_then(|path| {
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
})
.filter(|name| !name.is_empty())
}
#[cfg(target_os = "macos")]
fn macos_active_processes() -> Result<Vec<ProcessSnapshot>> {
let output = std::process::Command::new("ps")
.args(["-axo", "pid=,comm="])
.output()
.map_err(|err| {
rebecca::core::RebeccaError::PlatformUnavailable(format!(
"macOS ps process diagnostics are unavailable: {err}"
))
})?;
if !output.status.success() {
return Err(rebecca::core::RebeccaError::PlatformUnavailable(
"macOS ps process diagnostics exited unsuccessfully".to_string(),
)
.into());
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout
.lines()
.filter_map(macos_process_snapshot_from_ps_line)
.collect())
}
#[cfg(target_os = "macos")]
fn macos_process_snapshot_from_ps_line(line: &str) -> Option<ProcessSnapshot> {
let line = line.trim();
let (pid, command) = line.split_once(char::is_whitespace)?;
let process_id = pid.trim().parse::<u32>().ok()?;
let executable_name = std::path::Path::new(command.trim())
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.filter(|name| !name.is_empty())?;
Some(ProcessSnapshot {
process_id,
executable_name,
})
}
#[cfg(debug_assertions)]
fn active_process_snapshots_override() -> Option<Vec<ProcessSnapshot>> {
let raw = std::env::var("REBECCA_ACTIVE_PROCESSES").ok()?;
Some(
raw.split(';')
.enumerate()
.filter_map(|(index, process)| {
let process = process.trim();
if process.is_empty() {
return None;
}
let (name, pid) = process
.split_once(':')
.map(|(name, pid)| {
(
name.trim(),
pid.trim().parse::<u32>().unwrap_or(index as u32 + 1),
)
})
.unwrap_or((process, index as u32 + 1));
(!name.is_empty()).then(|| ProcessSnapshot {
process_id: pid,
executable_name: name.to_string(),
})
})
.collect(),
)
}
#[cfg(not(debug_assertions))]
fn active_process_snapshots_override() -> Option<Vec<ProcessSnapshot>> {
None
}
fn cleanup_rule_process_tokens(rule: &RuleDefinition) -> Vec<String> {
let mut tokens = Vec::new();
for token in explicit_cleanup_rule_process_tokens(&rule.id) {
tokens.push(token.to_string());
}
for source in [&rule.id, &rule.name] {
for token in source.split(|character: char| {
!character.is_ascii_alphanumeric() || character == '_' || character == '-'
}) {
let token = normalized_process_token(token);
if token.is_empty()
|| token == "windows"
|| token == "linux"
|| token == "macos"
|| token == "cache"
|| tokens.iter().any(|existing| existing == &token)
{
continue;
}
tokens.push(token);
}
}
tokens
}
fn explicit_cleanup_rule_process_tokens(rule_id: &str) -> &'static [&'static str] {
match rule_id {
"linux.brave-cache" => &["brave", "brave-browser"],
"linux.chrome-cache" => &["chrome", "google-chrome"],
"linux.chromium-cache" => &["chromium"],
"linux.edge-cache" => &["microsoft-edge", "msedge"],
"linux.firefox-profile-cache" => &["firefox"],
"linux.slack-cache" => &["slack"],
"linux.thunderbird-cache" => &["thunderbird"],
"linux.vlc-cache" => &["vlc"],
"linux.zoom-logs" => &["zoom"],
"linux.zen-browser-cache" => &["zen", "zen-browser", "zenbrowser"],
"macos.brave-cache" => &["brave", "brave browser"],
"macos.chrome-cache" => &["chrome", "google chrome"],
"macos.chromium-cache" => &["chromium"],
"macos.discord-cache" => &["discord", "discord ptb", "discord canary"],
"macos.edge-cache" => &["microsoft edge", "msedge"],
"macos.figma-cache" => &["figma"],
"macos.firefox-profile-cache" => &["firefox"],
"macos.notion-cache" => &["notion"],
"macos.postman-cache" => &["postman"],
"macos.slack-cache" => &["slack"],
"macos.thunderbird-cache" => &["thunderbird"],
"macos.vlc-cache" => &["vlc"],
"macos.vscode-cache" => &["code", "visual studio code"],
"macos.waterfox-cache" => &["waterfox"],
"macos.zoom-logs" => &["zoom", "zoom.us"],
"macos.zen-browser-cache" => &["zen", "zen browser", "zenbrowser"],
"windows.adobe-reader-cache" => &["acrobat", "acroread"],
"windows.teamviewer-logs" => &["teamviewer"],
"windows.thunderbird-cache" => &["thunderbird"],
"windows.vlc-cache" => &["vlc"],
"windows.zoom-logs" => &["zoom"],
"windows.zen-browser-cache" => &["zen", "zen-browser", "zenbrowser"],
_ => &[],
}
}
fn normalized_process_token(value: &str) -> String {
let value = value.trim().to_ascii_lowercase();
value.strip_suffix(".exe").unwrap_or(&value).to_string()
}
pub fn application_discovery() -> Box<dyn ApplicationDiscovery> {
if let Some(applications) = application_discovery_override() {
return applications;
}
#[cfg(windows)]
{
Box::new(rebecca::windows::steam::WindowsApplicationDiscovery::new())
}
#[cfg(target_os = "linux")]
{
Box::new(rebecca::core::applications::LinuxApplicationDiscovery::new())
}
#[cfg(target_os = "macos")]
{
Box::new(rebecca::core::applications::MacosApplicationDiscovery::new())
}
#[cfg(all(not(windows), not(target_os = "linux"), not(target_os = "macos")))]
{
Box::new(rebecca::core::applications::NoopApplicationDiscovery::new())
}
}
#[cfg(debug_assertions)]
fn application_discovery_override() -> Option<Box<dyn ApplicationDiscovery>> {
if app_discovery_is_disabled() {
return Some(Box::new(NoopApplicationDiscovery::new()));
}
let mut static_discovery = StaticApplicationDiscovery::new();
let mut has_override = false;
let steam_discovery = std::env::var("REBECCA_STEAM_DISCOVERY").ok();
if steam_discovery.as_deref().is_some_and(|value| {
value.eq_ignore_ascii_case("none") || value.eq_ignore_ascii_case("disabled")
}) {
has_override = true;
} else if let Ok(path) = std::env::var("REBECCA_STEAM_DISCOVERY_PATH") {
let path = path.trim();
has_override = true;
if !path.is_empty() {
static_discovery = static_discovery
.with_steam_installation(SteamInstallation::from_install_path_best_effort(path));
}
}
let installed_applications = installed_applications_override();
if !installed_applications.is_empty() {
has_override = true;
static_discovery = static_discovery.with_installed_applications(installed_applications);
}
has_override.then(|| Box::new(static_discovery) as Box<dyn ApplicationDiscovery>)
}
#[cfg(not(debug_assertions))]
fn application_discovery_override() -> Option<Box<dyn ApplicationDiscovery>> {
None
}
#[cfg(debug_assertions)]
fn app_discovery_is_disabled() -> bool {
std::env::var("REBECCA_APP_DISCOVERY")
.ok()
.is_some_and(|value| {
value.eq_ignore_ascii_case("none") || value.eq_ignore_ascii_case("disabled")
})
}
#[cfg(debug_assertions)]
fn installed_applications_override() -> Vec<InstalledApplication> {
let Ok(raw) = std::env::var("REBECCA_INSTALLED_APPLICATIONS") else {
return Vec::new();
};
raw.split(';')
.enumerate()
.filter_map(|(index, name)| {
let name = name.trim();
(!name.is_empty()).then(|| {
InstalledApplication::new(
format!("debug/installed-application/{index}"),
name,
Vec::new(),
)
})
})
.collect()
}
#[cfg(windows)]
fn current_privilege_label() -> &'static str {
match rebecca::windows::current_privilege_level() {
rebecca::windows::PrivilegeLevel::StandardUser => "standard-user",
rebecca::windows::PrivilegeLevel::Elevated => "elevated",
rebecca::windows::PrivilegeLevel::Unknown => "unknown",
}
}
#[cfg(target_os = "linux")]
fn current_privilege_label() -> &'static str {
match linux_effective_uid() {
Some(0) => "elevated",
Some(_) => "standard-user",
None => "unknown",
}
}
#[cfg(target_os = "linux")]
fn linux_effective_uid() -> Option<u32> {
let status = std::fs::read_to_string("/proc/self/status").ok()?;
status.lines().find_map(|line| {
let values = line.strip_prefix("Uid:")?;
values
.split_whitespace()
.nth(1)
.and_then(|effective_uid| effective_uid.parse::<u32>().ok())
})
}
#[cfg(target_os = "macos")]
fn current_privilege_label() -> &'static str {
match macos_effective_uid() {
Some(0) => "elevated",
Some(_) => "standard-user",
None => "unknown",
}
}
#[cfg(target_os = "macos")]
fn macos_effective_uid() -> Option<u32> {
let output = std::process::Command::new("id").arg("-u").output().ok()?;
if !output.status.success() {
return None;
}
String::from_utf8_lossy(&output.stdout)
.trim()
.parse::<u32>()
.ok()
}
#[cfg(all(not(windows), not(target_os = "linux"), not(target_os = "macos")))]
fn current_privilege_label() -> &'static str {
"unsupported-platform"
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::*;
#[test]
fn linux_active_processes_reads_comm_names_from_proc_root() {
let temp = tempfile::tempdir().unwrap();
let process_dir = temp.path().join("4242");
std::fs::create_dir_all(&process_dir).unwrap();
std::fs::write(process_dir.join("comm"), "firefox\n").unwrap();
std::fs::create_dir_all(temp.path().join("not-a-pid")).unwrap();
let processes = linux_active_processes_from_proc_root(temp.path()).unwrap();
assert_eq!(processes.len(), 1);
assert_eq!(processes[0].process_id, 4242);
assert_eq!(processes[0].executable_name, "firefox");
}
#[test]
fn linux_active_processes_reports_unavailable_proc_root() {
let temp = tempfile::tempdir().unwrap();
let missing = temp.path().join("missing-proc");
let error = linux_active_processes_from_proc_root(&missing)
.unwrap_err()
.to_string();
assert!(error.contains("Linux /proc process diagnostics are unavailable"));
}
}
#[cfg(all(test, target_os = "macos"))]
mod tests {
use super::*;
#[test]
fn macos_permission_advice_does_not_recommend_elevation() {
assert_eq!(
permission_suggested_action("elevated"),
macos_permission_suggested_action()
);
assert_eq!(
permission_suggested_action("standard-user"),
macos_permission_suggested_action()
);
assert!(!permission_suggested_action("elevated").contains("elevated macOS"));
}
#[test]
fn macos_privacy_diagnostic_maps_blocked_probe_to_gui_action() {
let diagnostic = macos_privacy_diagnostic_from_probes(vec![MacosPrivacyProbe {
label: "mail",
path: "/Users/alice/Library/Mail".to_string(),
status: MacosPrivacyProbeStatus::PermissionDenied,
}]);
assert_eq!(diagnostic.status, MacosPrivacyStatus::LikelyBlocked);
assert_eq!(
diagnostic.action_kind,
MacosPrivacyActionKind::GrantFullDiskAccessIfNeeded
);
assert!(diagnostic.full_disk_access_relevant);
assert!(diagnostic.affected_cleanup_families.contains(&"mail"));
assert!(
diagnostic
.affected_cleanup_families
.contains(&"user-library-cache")
);
}
#[test]
fn macos_privacy_diagnostic_maps_missing_probes_to_no_action() {
let diagnostic = macos_privacy_diagnostic_from_probes(vec![MacosPrivacyProbe {
label: "safari",
path: "/Users/alice/Library/Safari".to_string(),
status: MacosPrivacyProbeStatus::Missing,
}]);
assert_eq!(diagnostic.status, MacosPrivacyStatus::NotProbed);
assert_eq!(diagnostic.action_kind, MacosPrivacyActionKind::NoAction);
assert!(!diagnostic.full_disk_access_relevant);
assert!(diagnostic.affected_cleanup_families.is_empty());
}
#[test]
fn macos_process_snapshot_parses_ps_command_path() {
let process = macos_process_snapshot_from_ps_line(
" 4242 /Applications/Slack.app/Contents/MacOS/Slack",
)
.unwrap();
assert_eq!(process.process_id, 4242);
assert_eq!(process.executable_name, "Slack");
}
#[test]
fn macos_process_snapshot_ignores_invalid_ps_rows() {
assert!(macos_process_snapshot_from_ps_line("not-a-pid /Applications/Slack").is_none());
assert!(macos_process_snapshot_from_ps_line("4242").is_none());
}
}