From dd38e9b95ae93be371f62b49ab4d8367c188a516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ba=20R=E1=BB=8Di?= Date: Wed, 14 Oct 2026 17:07:45 +0700 Subject: [PATCH 01/17] feat(status): implement streaming watch mode and extended metrics (Phase 5) --- src/cli.rs | 147 ++++++++++- src/status/mod.rs | 329 +++++++++++++++++++++--- src/status/model.rs | 61 +++++ src/status/proc.rs | 468 ++++++++++++++++++++++++++++++++++- src/tui/status.rs | 81 +++++- tests/cli_integration.rs | 54 ++++ tests/golden/help-status.txt | 7 +- 7 files changed, 1103 insertions(+), 44 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index e130f4d..b4caf12 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -171,14 +171,17 @@ Displays a real-time system health dashboard with visual metric bars. Use --json to output a structured JSON snapshot and exit. When piped (stdout is not a terminal), JSON is used automatically. +Use --watch to stream snapshots continuously as NDJSON. Press q or Ctrl+C to exit the dashboard. Usage: mu status [flags] Flags: - -h, --help help for status - --json Output JSON snapshot and exit + -h, --help help for status + --interval duration Refresh interval for --watch (default 1s) + --json Output JSON snapshot and exit + --watch Stream status snapshots continuously as NDJSON Global Flags: --debug Enable verbose debug logging @@ -428,6 +431,8 @@ enum Command { }, Status { json: bool, + watch: bool, + interval: Option, }, Clean { dry_run: bool, @@ -506,7 +511,30 @@ pub fn run() -> ! { }); std::process::exit(code); } - Command::Status { json } => std::process::exit(status::run(json)), + Command::Status { + json, + watch, + interval, + } => { + let dur = if let Some(i_str) = interval { + match parse_duration(&i_str) { + Some(d) => d, + None => { + eprintln!( + "invalid argument \"{i_str}\" for \"--interval\" flag: time: invalid duration \"{i_str}\"" + ); + std::process::exit(1); + } + } + } else { + std::time::Duration::from_secs(1) + }; + if watch { + std::process::exit(status::run_watch(dur)); + } else { + std::process::exit(status::run(json)); + } + } Command::Optimize { dry_run, skip, yes } => { let code = match optimize::run(&optimize::Options { dry_run, @@ -676,6 +704,51 @@ const F_NODESC: FlagSpec = FlagSpec { takes_value: false, display: "--no-descriptions", }; +const F_WATCH: FlagSpec = FlagSpec { + long: "watch", + short: None, + takes_value: false, + display: "--watch", +}; +const F_INTERVAL: FlagSpec = FlagSpec { + long: "interval", + short: None, + takes_value: true, + display: "--interval", +}; + +fn parse_duration(s: &str) -> Option { + let s = s.trim(); + if s.is_empty() { + return None; + } + if let Some(ms_str) = s.strip_suffix("ms") { + ms_str + .parse::() + .ok() + .map(std::time::Duration::from_millis) + } else if let Some(s_str) = s.strip_suffix('s') { + s_str.parse::().ok().and_then(|v| { + if v >= 0.0 { + Some(std::time::Duration::from_secs_f64(v)) + } else { + None + } + }) + } else if let Some(m_str) = s.strip_suffix('m') { + m_str.parse::().ok().and_then(|v| { + if v >= 0.0 { + Some(std::time::Duration::from_secs_f64(v * 60.0)) + } else { + None + } + }) + } else if let Ok(secs) = s.parse::() { + Some(std::time::Duration::from_secs(secs)) + } else { + None + } +} const ROOT_FLAGS: &[FlagSpec] = &[F_DEBUG, F_HELP, F_VERSION]; const HELP_FLAGS: &[FlagSpec] = &[F_DEBUG, F_HELP]; @@ -685,7 +758,7 @@ const SHELL_FLAGS: &[FlagSpec] = &[F_DEBUG, F_HELP, F_NODESC]; fn cmd_flagset(name: &str) -> &'static [FlagSpec] { match name { "analyze" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_JSON], - "status" => &[F_DEBUG, F_HELP, F_JSON], + "status" => &[F_DEBUG, F_HELP, F_INTERVAL, F_JSON, F_WATCH], "clean" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_YES, F_INCLUDE], "optimize" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_YES, F_SKIP], "audit" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_JSON, F_REPORT, F_INCLUDE], @@ -1009,9 +1082,19 @@ fn exec_subcommand(name: &str, args: &[String]) -> Outcome { dry_run: p.get("dry-run"), json: p.get("json"), }, - "status" => Command::Status { - json: p.get("json"), - }, + "status" => { + let interval = p.vals("interval").into_iter().next(); + if let Some(i_str) = interval.as_deref().filter(|s| parse_duration(s).is_none()) { + return perr(format!( + "invalid argument \"{i_str}\" for \"--interval\" flag: time: invalid duration \"{i_str}\"\n" + )); + } + Command::Status { + json: p.get("json"), + watch: p.get("watch"), + interval, + } + } "clean" => Command::Clean { dry_run: p.get("dry-run"), include: p.vals("include"), @@ -1271,7 +1354,7 @@ mod tests { fn positional_args_ignored() { match parse(&args(&["status", "foo"])) { Outcome::Run { cmd, .. } => match cmd { - Command::Status { json } => assert!(!json), + Command::Status { json, .. } => assert!(!json), _ => panic!("expected Status"), }, _ => panic!("expected Run"), @@ -1873,4 +1956,52 @@ mod tests { include_str!("../tests/golden/help-uninstall.txt") ); } + + #[test] + fn status_watch_and_interval_flags_parse() { + match parse(&args(&["status", "--watch", "--interval", "2s"])) { + Outcome::Run { cmd, .. } => match cmd { + Command::Status { + json, + watch, + interval, + } => { + assert!(!json); + assert!(watch); + assert_eq!(interval.as_deref(), Some("2s")); + } + _ => panic!("expected Status"), + }, + _ => panic!("expected Run"), + } + } + + #[test] + fn status_invalid_interval_flag() { + match parse(&args(&["status", "--interval", "bogus"])) { + Outcome::Stderr { text, code } => { + assert_eq!(code, 1); + assert!(text.contains("invalid duration \"bogus\""), "{text}"); + } + _ => panic!("expected Stderr"), + } + } + + #[test] + fn parse_duration_formats() { + assert_eq!( + parse_duration("1s"), + Some(std::time::Duration::from_secs(1)) + ); + assert_eq!( + parse_duration("500ms"), + Some(std::time::Duration::from_millis(500)) + ); + assert_eq!( + parse_duration("2m"), + Some(std::time::Duration::from_secs(120)) + ); + assert_eq!(parse_duration("5"), Some(std::time::Duration::from_secs(5))); + assert_eq!(parse_duration("bogus"), None); + } } diff --git a/src/status/mod.rs b/src/status/mod.rs index 123d1fc..d160e76 100644 --- a/src/status/mod.rs +++ b/src/status/mod.rs @@ -12,7 +12,10 @@ pub mod model; pub mod proc; pub use health::health_score_available; -pub use model::{CPUSample, DiskStat, MemStats, NetRate, NetStat, Snapshot}; +pub use model::{ + BatteryInfo, CPUSample, DiskIORate, DiskIOSample, DiskStat, HardwareInfo, MemStats, NetRate, + NetStat, ProcessInfo, Snapshot, +}; // Re-exported for the TUI phase (view.go ports) — not yet consumed. #[allow(unused_imports)] pub use health::health_score; @@ -32,28 +35,56 @@ use crate::error::{Error, Result}; /// errors, matching `proc::read_disk_full`. pub type DiskReader = Box (Vec, Option)>; +/// DiskIOReader returns (map of device to DiskIOSample, scan error). +pub type DiskIOReader = Box (BTreeMap, Option)>; + /// Go's `readNetworkFn` — `(values, joinedErr)` pair, matching /// `proc::read_network_full`. pub type NetReader = Box (BTreeMap, Option)>; /// Injectable metric readers — the port of Go's package-var hooks /// (`readCPUFn`, `readMemoryFn`, `readDiskFn`, `readNetworkFn`) that -/// `status_test.go` swaps out for fakes. +/// `status_test.go` swaps out for fakes, plus Phase 5 extended collectors. pub struct Readers { pub cpu: Box Result>, + pub cpu_cores: Box Result>>, pub memory: Box Result>, pub disk: DiskReader, + pub disk_io: DiskIOReader, pub network: NetReader, + pub hardware: Box HardwareInfo>, + pub battery: Box Result>>, + pub processes: Box Result>>, } +impl Default for Readers { + fn default() -> Self { + Readers { + cpu: Box::new(|| Ok(CPUSample::default())), + cpu_cores: Box::new(|| Ok(Vec::new())), + memory: Box::new(|| Ok(MemStats::default())), + disk: Box::new(|| (Vec::new(), None)), + disk_io: Box::new(|| (BTreeMap::new(), None)), + network: Box::new(|| (BTreeMap::new(), None)), + hardware: Box::new(HardwareInfo::default), + battery: Box::new(|| Ok(None)), + processes: Box::new(|| Ok(Vec::new())), + } + } +} impl Readers { /// Readers bound to the real /proc + mountinfo parsers. pub fn real() -> Self { Readers { cpu: Box::new(proc::read_cpu), + cpu_cores: Box::new(proc::read_cpu_cores), memory: Box::new(proc::read_memory), disk: Box::new(proc::read_disk_full), + disk_io: Box::new(proc::read_disk_io_full), network: Box::new(proc::read_network_full), + hardware: Box::new(proc::read_hardware), + battery: Box::new(proc::read_battery), + processes: Box::new(proc::read_top_processes), } } } @@ -78,19 +109,38 @@ pub fn collect_snapshot(interval: Duration) -> Snapshot { pub fn collect_snapshot_with(interval: Duration, readers: &mut Readers) -> Snapshot { let mut snap = Snapshot::default(); + // Sample initial rates + let cpu1 = (readers.cpu)(); + let cores1 = (readers.cpu_cores)(); + let (disk_io1, _) = (readers.disk_io)(); + let net1 = (readers.network)(); + + wait(interval); + let mut cpu_available = false; - match (readers.cpu)() { + match cpu1 { Err(err) => snap.scan_errors.push(format!("cpu: {err}")), - Ok(s1) => { - wait(interval); - match (readers.cpu)() { - Err(second_err) => snap.scan_errors.push(format!("cpu: {second_err}")), - Ok(s2) => { - snap.cpu_percent = cpu_percent(s1, s2); - cpu_available = true; - } + Ok(s1) => match (readers.cpu)() { + Err(second_err) => snap.scan_errors.push(format!("cpu: {second_err}")), + Ok(s2) => { + snap.cpu_percent = cpu_percent(s1, s2); + cpu_available = true; } - } + }, + } + + if let (Ok(c1), Ok(c2)) = (cores1, (readers.cpu_cores)()) { + snap.cpu_cores = + (!c1.is_empty() && !c2.is_empty()).then(|| proc::cpu_cores_percent(&c1, &c2)); + } + + let seconds = interval.as_secs_f64(); + let seconds = if seconds <= 0.0 { 1.0 } else { seconds }; + + let (disk_io2, _) = (readers.disk_io)(); + if !disk_io1.is_empty() || !disk_io2.is_empty() { + let rates = proc::disk_io_rates(&disk_io1, &disk_io2, seconds); + snap.disk_io = Some(rates); } let mut mem_available = false; @@ -109,24 +159,32 @@ pub fn collect_snapshot_with(interval: Duration, readers: &mut Readers) -> Snaps snap.scan_errors.push(format!("disk: {disk_err}")); } - match (readers.network)() { + match net1 { (_, Some(net_err)) => snap.scan_errors.push(format!("network: {net_err}")), - (n1, None) => { - wait(interval); - match (readers.network)() { - (_, Some(second_err)) => { - snap.scan_errors.push(format!("network: {second_err}")); - } - (n2, None) => { - let seconds = interval.as_secs_f64(); - let seconds = if seconds <= 0.0 { 1.0 } else { seconds }; - // Go: NetworkRates returns make(map) → `{}` even when - // empty; Some(_) distinguishes success from the nil/null - // error paths above. - snap.network = Some(network_rates(&n1, &n2, seconds)); - } + (n1, None) => match (readers.network)() { + (_, Some(second_err)) => { + snap.scan_errors.push(format!("network: {second_err}")); } - } + (n2, None) => { + // Go: NetworkRates returns make(map) → `{}` even when + // empty; Some(_) distinguishes success from the nil/null + // error paths above. + snap.network = Some(network_rates(&n1, &n2, seconds)); + } + }, + } + + let hw = (readers.hardware)(); + if hw.model.is_some() || hw.cpu_model.is_some() || hw.cpu_cores.is_some() { + snap.hardware = Some(hw); + } + + if let Ok(Some(bat)) = (readers.battery)() { + snap.battery = Some(bat); + } + + if let Ok(procs) = (readers.processes)() { + snap.processes = (!procs.is_empty()).then_some(procs); } snap.health = health_score_available( @@ -139,6 +197,11 @@ pub fn collect_snapshot_with(interval: Duration, readers: &mut Readers) -> Snaps snap } +/// Snapshot JSON helper: one-shot collection with default 1s interval. +pub fn collect_snapshot_json() -> Snapshot { + collect_snapshot(Duration::from_secs(1)) +} + /// Dashboard ports Go's `Model` tick-time state machine for the later TUI. /// `tick` mirrors the `tickMsg` branch of `Model.Update`; Bubbletea's /// view/key handling and tick scheduling are deferred to the ratatui phase. @@ -147,13 +210,20 @@ pub struct Dashboard { /// zero-value compare is kept verbatim: an all-zero read counts as /// "no previous sample" and a failed read resets it, exactly like Go. pub prev_cpu: CPUSample, + pub prev_cpu_cores: Vec, pub cpu: f64, + pub cpu_cores: Vec, pub cpu_ready: bool, pub mem: MemStats, pub disks: Vec, + pub prev_disk_io: Option>, + pub disk_io_rates: BTreeMap, /// `None` mirrors Go's nil map — `Some(empty)` still counts as present. pub prev_nets: Option>, pub net_rates: BTreeMap, + pub hardware: HardwareInfo, + pub battery: Option, + pub processes: Vec, pub health: i64, /// Timestamp of the previous tick (Go `prevTime`); the network rate /// interval is `prev_time.elapsed()` clamped to >= 1s like Go. @@ -192,6 +262,14 @@ impl Dashboard { } } + // 1b. Per-core CPU + if let Ok(curr_cores) = (readers.cpu_cores)() { + if !self.prev_cpu_cores.is_empty() && !curr_cores.is_empty() { + self.cpu_cores = proc::cpu_cores_percent(&self.prev_cpu_cores, &curr_cores); + } + self.prev_cpu_cores = curr_cores; + } + // 2. Read memory. match (readers.memory)() { Err(mem_err) => self.scan_errors.push(format!("memory: {mem_err}")), @@ -208,11 +286,18 @@ impl Dashboard { self.scan_errors.push(format!("disk: {disk_err}")); } + // 3b. Read disk I/O + let elapsed = self.prev_time.elapsed().as_secs_f64(); + let elapsed = if elapsed <= 0.0 { 1.0 } else { elapsed }; + let (curr_disk_io, _) = (readers.disk_io)(); + if let Some(prev_io) = &self.prev_disk_io { + self.disk_io_rates = proc::disk_io_rates(prev_io, &curr_disk_io, elapsed); + } + self.prev_disk_io = Some(curr_disk_io); + // 4. Read network; compute rates from the previous sample. match (readers.network)() { (curr_nets, None) => { - let elapsed = self.prev_time.elapsed().as_secs_f64(); - let elapsed = if elapsed <= 0.0 { 1.0 } else { elapsed }; if let Some(prev_nets) = &self.prev_nets { self.net_rates = network_rates(prev_nets, &curr_nets, elapsed); } @@ -222,6 +307,11 @@ impl Dashboard { } self.prev_time = Instant::now(); + // 4b. Hardware, battery, processes + self.hardware = (readers.hardware)(); + self.battery = (readers.battery)().unwrap_or(None); + self.processes = (readers.processes)().unwrap_or_default(); + // 5. Compute health. self.health = health_score_available( self.cpu, @@ -237,12 +327,19 @@ impl Default for Dashboard { fn default() -> Self { Dashboard { prev_cpu: CPUSample::default(), + prev_cpu_cores: Vec::new(), cpu: 0.0, + cpu_cores: Vec::new(), cpu_ready: false, mem: MemStats::default(), disks: Vec::new(), + prev_disk_io: None, + disk_io_rates: BTreeMap::new(), prev_nets: None, net_rates: BTreeMap::new(), + hardware: HardwareInfo::default(), + battery: None, + processes: Vec::new(), health: 0, prev_time: Instant::now(), scan_errors: Vec::new(), @@ -288,6 +385,121 @@ pub fn run(json: bool) -> i32 { } } +/// RunWatch continuously streams status snapshots as NDJSON lines until cancelled +/// or downstream pipe closes. +pub fn run_watch(interval: Duration) -> i32 { + let mut readers = Readers::real(); + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + + let mut prev_cpu = (readers.cpu)().unwrap_or_default(); + let mut prev_cores = (readers.cpu_cores)().unwrap_or_default(); + let (mut prev_disk_io, _) = (readers.disk_io)(); + let (mut prev_net, _) = (readers.network)(); + let mut prev_time = Instant::now(); + + loop { + wait(interval); + + let mut snap = Snapshot::default(); + let elapsed = prev_time.elapsed().as_secs_f64(); + let elapsed = if elapsed <= 0.0 { 1.0 } else { elapsed }; + prev_time = Instant::now(); + + let mut cpu_available = false; + match (readers.cpu)() { + Ok(curr_cpu) => { + if prev_cpu != CPUSample::default() { + snap.cpu_percent = cpu_percent(prev_cpu, curr_cpu); + cpu_available = true; + } + prev_cpu = curr_cpu; + } + Err(e) => { + snap.scan_errors.push(format!("cpu: {e}")); + prev_cpu = CPUSample::default(); + } + } + + if let Ok(curr_cores) = (readers.cpu_cores)() { + if !prev_cores.is_empty() && !curr_cores.is_empty() { + snap.cpu_cores = Some(proc::cpu_cores_percent(&prev_cores, &curr_cores)); + } + prev_cores = curr_cores; + } + + let (curr_disk_io, _) = (readers.disk_io)(); + if !prev_disk_io.is_empty() || !curr_disk_io.is_empty() { + snap.disk_io = Some(proc::disk_io_rates(&prev_disk_io, &curr_disk_io, elapsed)); + } + prev_disk_io = curr_disk_io; + + match (readers.network)() { + (curr_net, None) => { + snap.network = Some(network_rates(&prev_net, &curr_net, elapsed)); + prev_net = curr_net; + } + (_, Some(e)) => { + snap.scan_errors.push(format!("network: {e}")); + } + } + + let mut mem_available = false; + match (readers.memory)() { + Ok(mem) => { + snap.memory = mem; + mem_available = true; + } + Err(e) => snap.scan_errors.push(format!("memory: {e}")), + } + + let (disks, disk_err) = (readers.disk)(); + snap.disks = if disks.is_empty() { None } else { Some(disks) }; + if let Some(e) = disk_err { + snap.scan_errors.push(format!("disk: {e}")); + } + + let hw = (readers.hardware)(); + if hw.model.is_some() || hw.cpu_model.is_some() || hw.cpu_cores.is_some() { + snap.hardware = Some(hw); + } + if let Ok(Some(bat)) = (readers.battery)() { + snap.battery = Some(bat); + } + if let Ok(procs) = (readers.processes)() { + snap.processes = (!procs.is_empty()).then_some(procs); + } + + snap.health = health_score_available( + snap.cpu_percent, + cpu_available, + snap.memory, + mem_available, + snap.disks.as_deref().unwrap_or(&[]), + ); + + let body = match serde_json::to_string(&snap) { + Ok(b) => model::escape_html_json(&b), + Err(e) => { + eprintln!("{e}"); + return 1; + } + }; + + if let Err(e) = out + .write_all(body.as_bytes()) + .and_then(|()| out.write_all(b"\n")) + .and_then(|()| out.flush()) + { + if e.kind() == std::io::ErrorKind::BrokenPipe { + return 0; + } + eprintln!("{e}"); + return 1; + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -305,6 +517,7 @@ mod tests { Some(Error::Msg("network unavailable".to_string())), ) }), + ..Readers::default() } } @@ -411,6 +624,7 @@ mod tests { ) }), network: Box::new(|| (BTreeMap::new(), None)), + ..Readers::default() }; let mut dashboard = Dashboard::new(); @@ -473,6 +687,7 @@ mod tests { ); (nets, None) }), + ..Readers::default() }; let snap = collect_snapshot_with(Duration::ZERO, &mut readers); assert!(snap.scan_errors.is_empty()); @@ -495,4 +710,58 @@ mod tests { .expect("scan_errors must be emitted when metrics fail"); assert_eq!(errors.len(), 4); } + + #[test] + fn snapshot_json_includes_extended_fields_when_present() { + let mut readers = Readers { + cpu_cores: Box::new(|| { + Ok(vec![CPUSample { + user: 10, + idle: 90, + ..CPUSample::default() + }]) + }), + disk_io: Box::new(|| { + let mut map = BTreeMap::new(); + map.insert( + "sda".to_string(), + DiskIOSample { + reads: 10, + sectors_read: 100, + writes: 5, + sectors_written: 50, + }, + ); + (map, None) + }), + hardware: Box::new(|| HardwareInfo { + model: Some("TestBox".to_string()), + cpu_model: Some("FastCPU".to_string()), + cpu_cores: Some(4), + }), + battery: Box::new(|| { + Ok(Some(BatteryInfo { + percentage: 95, + status: "Charging".to_string(), + health_percent: Some(99.0), + })) + }), + processes: Box::new(|| { + Ok(vec![ProcessInfo { + pid: 1, + name: "systemd".to_string(), + cpu_percent: 0.5, + memory_bytes: 1024, + }]) + }), + ..Readers::default() + }; + let snap = collect_snapshot_with(Duration::ZERO, &mut readers); + let v: serde_json::Value = serde_json::to_value(&snap).unwrap(); + assert!(v.get("cpu_cores").is_some(), "missing cpu_cores"); + assert!(v.get("disk_io").is_some(), "missing disk_io"); + assert!(v.get("hardware").is_some(), "missing hardware"); + assert!(v.get("battery").is_some(), "missing battery"); + assert!(v.get("processes").is_some(), "missing processes"); + } } diff --git a/src/status/model.rs b/src/status/model.rs index 3ffef19..002728a 100644 --- a/src/status/model.rs +++ b/src/status/model.rs @@ -23,6 +23,16 @@ pub struct Snapshot { pub disks: Option>, pub network: Option>, pub health: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub cpu_cores: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub disk_io: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub hardware: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub battery: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub processes: Option>, #[serde(rename = "scan_errors", skip_serializing_if = "Vec::is_empty")] pub scan_errors: Vec, } @@ -111,6 +121,57 @@ pub struct CPUSample { pub steal: u64, } +/// DiskIORate holds per-second I/O throughput rates for one device. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct DiskIORate { + #[serde(rename = "read_bytes_per_sec")] + pub read_bytes_per_sec: u64, + #[serde(rename = "write_bytes_per_sec")] + pub write_bytes_per_sec: u64, + #[serde(rename = "read_iops", skip_serializing_if = "Option::is_none")] + pub read_iops: Option, + #[serde(rename = "write_iops", skip_serializing_if = "Option::is_none")] + pub write_iops: Option, +} + +/// DiskIOSample holds raw /proc/diskstats counters for one device. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct DiskIOSample { + pub reads: u64, + pub sectors_read: u64, + pub writes: u64, + pub sectors_written: u64, +} + +/// HardwareInfo holds hardware and CPU specifications. +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)] +pub struct HardwareInfo { + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cpu_model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cpu_cores: Option, +} + +/// BatteryInfo holds battery charge, status, and health metrics. +#[derive(Debug, Default, Clone, PartialEq, Serialize)] +pub struct BatteryInfo { + pub percentage: u8, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub health_percent: Option, +} + +/// ProcessInfo holds process resource consumption metrics. +#[derive(Debug, Default, Clone, PartialEq, Serialize)] +pub struct ProcessInfo { + pub pid: u32, + pub name: String, + pub cpu_percent: f64, + pub memory_bytes: u64, +} + /// humanKB mirrors the Go helper: 0 → "0 B", else kb*1024 via humanBytes. pub fn human_kb(kb: u64) -> String { if kb == 0 { diff --git a/src/status/proc.rs b/src/status/proc.rs index 9a4cdb7..19af405 100644 --- a/src/status/proc.rs +++ b/src/status/proc.rs @@ -12,10 +12,14 @@ use std::collections::{BTreeMap, HashSet}; use std::fs::File; use std::io::{BufRead, BufReader}; use std::os::unix::fs::MetadataExt; +use std::path::Path; use crate::error::{Error, Result}; -use super::model::{CPUSample, DiskStat, MemStats, NetRate, NetStat}; +use super::model::{ + BatteryInfo, CPUSample, DiskIORate, DiskIOSample, DiskStat, HardwareInfo, MemStats, NetRate, + NetStat, ProcessInfo, +}; /// Go `os.Open` surfaces `*PathError` — `open /proc/stat: no such file or /// directory`. Rust's io::Error Display is `No such file or directory @@ -76,6 +80,59 @@ fn read_cpu_from(reader: impl BufRead) -> Result { Err(Error::Msg("no cpu line found in /proc/stat".to_string())) } +/// ReadCPUCores parses per-core jiffies (`cpu0`, `cpu1`, ...) from /proc/stat. +pub fn read_cpu_cores() -> Result> { + let f = File::open("/proc/stat").map_err(|e| open_err("/proc/stat", e))?; + read_cpu_cores_from(BufReader::new(f)) +} + +pub fn read_cpu_cores_from(reader: impl BufRead) -> Result> { + let mut cores = Vec::new(); + for line in reader.lines() { + let Ok(line) = line else { break }; + if !line.starts_with("cpu") { + continue; + } + let bytes = line.as_bytes(); + if bytes.len() < 4 || !bytes[3].is_ascii_digit() { + continue; + } + let fields: Vec<&str> = line.split_whitespace().collect(); + if fields.len() < 9 { + return Err(Error::Msg(format!( + "unexpected /proc/stat format: {line:?}" + ))); + } + let mut values = [0u64; 8]; + for (i, v) in values.iter_mut().enumerate() { + *v = fields[i + 1] + .parse::() + .map_err(|e| Error::Msg(format!("parse /proc/stat field {}: {e}", i + 1)))?; + } + cores.push(CPUSample { + user: values[0], + nice: values[1], + system: values[2], + idle: values[3], + iowait: values[4], + irq: values[5], + softirq: values[6], + steal: values[7], + }); + } + Ok(cores) +} + +/// CPUCoresPercent computes per-core usage % between two samples. +pub fn cpu_cores_percent(prev: &[CPUSample], curr: &[CPUSample]) -> Vec { + let n = prev.len().min(curr.len()); + let mut res = Vec::with_capacity(n); + for i in 0..n { + res.push(cpu_percent(prev[i], curr[i])); + } + res +} + /// CPUPercent computes CPU usage % between two samples. /// Returns 0 if delta_total == 0 or if prev is zero-value (first sample). pub fn cpu_percent(prev: CPUSample, curr: CPUSample) -> f64 { @@ -469,6 +526,267 @@ pub fn network_rates( rates } +/// ReadDiskIO parses /proc/diskstats for disk I/O metrics. +#[allow(dead_code)] +pub fn read_disk_io() -> Result> { + let (samples, err) = read_disk_io_full(); + if let Some(err) = err { + return Err(err); + } + Ok(samples) +} + +/// ReadDiskIOFull returns (samples, joined scan errors). +pub fn read_disk_io_full() -> (BTreeMap, Option) { + match File::open("/proc/diskstats") { + Ok(f) => read_disk_io_from(BufReader::new(f)), + Err(e) => (BTreeMap::new(), Some(open_err("/proc/diskstats", e))), + } +} + +pub fn read_disk_io_from(reader: impl BufRead) -> (BTreeMap, Option) { + let mut samples = BTreeMap::new(); + let mut scan_errors = Vec::new(); + for line in reader.lines() { + let Ok(line) = line else { break }; + let fields: Vec<&str> = line.split_whitespace().collect(); + if fields.len() < 11 { + continue; + } + let dev_name = fields[2]; + if dev_name.starts_with("loop") + || dev_name.starts_with("ram") + || dev_name.starts_with("zram") + { + continue; + } + let reads = match fields[3].parse::() { + Ok(v) => v, + Err(e) => { + scan_errors.push(format!("parse /proc/diskstats {dev_name} reads: {e}")); + continue; + } + }; + let sectors_read = match fields[5].parse::() { + Ok(v) => v, + Err(e) => { + scan_errors.push(format!( + "parse /proc/diskstats {dev_name} sectors_read: {e}" + )); + continue; + } + }; + let writes = match fields[7].parse::() { + Ok(v) => v, + Err(e) => { + scan_errors.push(format!("parse /proc/diskstats {dev_name} writes: {e}")); + continue; + } + }; + let sectors_written = match fields[9].parse::() { + Ok(v) => v, + Err(e) => { + scan_errors.push(format!( + "parse /proc/diskstats {dev_name} sectors_written: {e}" + )); + continue; + } + }; + samples.insert( + dev_name.to_string(), + DiskIOSample { + reads, + sectors_read, + writes, + sectors_written, + }, + ); + } + (samples, join_errors(scan_errors)) +} + +/// DiskIORates computes per-second byte and IO throughput from two snapshots. +pub fn disk_io_rates( + prev: &BTreeMap, + curr: &BTreeMap, + elapsed_sec: f64, +) -> BTreeMap { + let elapsed = if elapsed_sec <= 0.0 { 1.0 } else { elapsed_sec }; + let mut rates = BTreeMap::new(); + for (dev, c) in curr { + if let Some(p) = prev.get(dev) { + let read_sectors = c.sectors_read.saturating_sub(p.sectors_read); + let write_sectors = c.sectors_written.saturating_sub(p.sectors_written); + let read_ops = c.reads.saturating_sub(p.reads); + let write_ops = c.writes.saturating_sub(p.writes); + let read_bytes = read_sectors * 512; + let write_bytes = write_sectors * 512; + rates.insert( + dev.clone(), + DiskIORate { + read_bytes_per_sec: (read_bytes as f64 / elapsed).round() as u64, + write_bytes_per_sec: (write_bytes as f64 / elapsed).round() as u64, + read_iops: Some((read_ops as f64 / elapsed).round() as u64), + write_iops: Some((write_ops as f64 / elapsed).round() as u64), + }, + ); + } + } + rates +} + +/// ReadHardware gathers system and CPU specs from /proc/cpuinfo and /sys/class/dmi. +pub fn read_hardware() -> HardwareInfo { + let cpuinfo = File::open("/proc/cpuinfo").ok(); + let dmi_prod = std::fs::read_to_string("/sys/class/dmi/id/product_name").ok(); + let dmi_vendor = std::fs::read_to_string("/sys/class/dmi/id/sys_vendor").ok(); + read_hardware_from( + cpuinfo.map(BufReader::new), + dmi_prod.as_deref(), + dmi_vendor.as_deref(), + ) +} + +pub fn read_hardware_from( + cpuinfo_reader: Option, + dmi_product: Option<&str>, + dmi_vendor: Option<&str>, +) -> HardwareInfo { + let mut cpu_model = None; + let mut cpu_cores = 0; + if let Some(reader) = cpuinfo_reader { + for line in reader.lines().map_while(std::result::Result::ok) { + if line.starts_with("model name") && cpu_model.is_none() { + if let Some((_, val)) = line.split_once(':') { + cpu_model = Some(val.trim().to_string()); + } + } else if line.starts_with("processor") { + cpu_cores += 1; + } + } + } + let model = match (dmi_vendor.map(str::trim), dmi_product.map(str::trim)) { + (Some(v), Some(p)) if !v.is_empty() && !p.is_empty() && !p.starts_with(v) => { + Some(format!("{v} {p}")) + } + (_, Some(p)) if !p.is_empty() => Some(p.to_string()), + (Some(v), _) if !v.is_empty() => Some(v.to_string()), + _ => None, + }; + HardwareInfo { + model, + cpu_model, + cpu_cores: if cpu_cores > 0 { Some(cpu_cores) } else { None }, + } +} + +/// ReadBattery parses battery charge, health, and status from /sys/class/power_supply/BAT*. +pub fn read_battery() -> Result> { + read_battery_from(Path::new("/sys/class/power_supply")) +} + +pub fn read_battery_from(ps_dir: &Path) -> Result> { + if !ps_dir.exists() { + return Ok(None); + } + let entries = match std::fs::read_dir(ps_dir) { + Ok(e) => e, + Err(_) => return Ok(None), + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with("BAT") || name_str.starts_with("bat") { + let dir = entry.path(); + let cap_str = std::fs::read_to_string(dir.join("capacity")).unwrap_or_default(); + let percentage = cap_str.trim().parse::().unwrap_or(0); + let status_str = std::fs::read_to_string(dir.join("status")).unwrap_or_default(); + let status = status_str.trim().to_string(); + + let health_percent = { + let full = std::fs::read_to_string(dir.join("energy_full")) + .or_else(|_| std::fs::read_to_string(dir.join("charge_full"))) + .ok() + .and_then(|s| s.trim().parse::().ok()); + let design = std::fs::read_to_string(dir.join("energy_full_design")) + .or_else(|_| std::fs::read_to_string(dir.join("charge_full_design"))) + .ok() + .and_then(|s| s.trim().parse::().ok()); + match (full, design) { + (Some(f), Some(d)) if d > 0.0 => Some(((f / d) * 100.0).clamp(0.0, 100.0)), + _ => None, + } + }; + + return Ok(Some(BatteryInfo { + percentage, + status: if status.is_empty() { + "Unknown".to_string() + } else { + status + }, + health_percent, + })); + } + } + Ok(None) +} + +/// ReadTopProcesses reads top resource-consuming processes from /proc/[pid]/stat. +pub fn read_top_processes() -> Result> { + read_top_processes_from(Path::new("/proc"), 5) +} + +pub fn read_top_processes_from(proc_dir: &Path, limit: usize) -> Result> { + if !proc_dir.exists() { + return Ok(Vec::new()); + } + let entries = match std::fs::read_dir(proc_dir) { + Ok(e) => e, + Err(_) => return Ok(Vec::new()), + }; + let mut procs = Vec::new(); + for entry in entries.flatten() { + let file_name = entry.file_name(); + let name_str = file_name.to_string_lossy(); + let Ok(pid) = name_str.parse::() else { + continue; + }; + let stat_path = entry.path().join("stat"); + let Ok(stat_content) = std::fs::read_to_string(stat_path) else { + continue; + }; + let Some(open_paren) = stat_content.find('(') else { + continue; + }; + let Some(close_paren) = stat_content.rfind(')') else { + continue; + }; + let proc_name = &stat_content[open_paren + 1..close_paren]; + let remainder = &stat_content[close_paren + 1..]; + let fields: Vec<&str> = remainder.split_whitespace().collect(); + if fields.len() > 21 { + let utime = fields[11].parse::().unwrap_or(0); + let stime = fields[12].parse::().unwrap_or(0); + let rss_pages = fields[21].parse::().unwrap_or(0); + let total_ticks = utime + stime; + let memory_bytes = rss_pages * 4096; + procs.push(( + total_ticks, + ProcessInfo { + pid, + name: proc_name.to_string(), + cpu_percent: 0.0, + memory_bytes, + }, + )); + } + } + procs.sort_by_key(|a| std::cmp::Reverse(a.0)); + procs.truncate(limit); + Ok(procs.into_iter().map(|(_, p)| p).collect()) +} + #[cfg(test)] mod tests { use super::*; @@ -909,4 +1227,152 @@ mod tests { assert_eq!(r.rx_bytes_per_sec, 500); // 500.5 truncated assert_eq!(r.tx_bytes_per_sec, 499); // 499.5 truncated } + + // -- Phase 5 Extended Metrics Tests -- + + #[test] + fn read_cpu_cores_parses_fixture() { + let cores = read_cpu_cores_from(include_str!("testdata/proc_stat.txt").as_bytes()).unwrap(); + assert_eq!(cores.len(), 2); + assert_eq!(cores[0].user, 617283); + assert_eq!(cores[1].user, 617284); + } + + #[test] + fn cpu_cores_percent_computes_per_core() { + let s1 = vec![ + CPUSample { + user: 100, + idle: 900, + ..CPUSample::default() + }, + CPUSample { + user: 200, + idle: 800, + ..CPUSample::default() + }, + ]; + let s2 = vec![ + CPUSample { + user: 200, + idle: 900, + ..CPUSample::default() + }, + CPUSample { + user: 400, + idle: 800, + ..CPUSample::default() + }, + ]; + let pcts = cpu_cores_percent(&s1, &s2); + assert_eq!(pcts.len(), 2); + assert_eq!(pcts[0], 100.0); + assert_eq!(pcts[1], 100.0); + } + + #[test] + fn read_disk_io_parses_sample_and_filters_virtual() { + let input = b"\ + 7 0 loop0 100 0 1000 50 200 0 2000 60 0 10 20 + 8 0 sda 500 10 10000 120 300 20 8000 140 0 20 50 + 259 0 nvme0n1 1200 50 40000 200 800 30 24000 300 0 30 90 +"; + let (samples, err) = read_disk_io_from(input.as_slice()); + assert!(err.is_none()); + assert_eq!(samples.len(), 2); + assert!(!samples.contains_key("loop0")); + assert!(samples.contains_key("sda")); + assert!(samples.contains_key("nvme0n1")); + + let sda = &samples["sda"]; + assert_eq!(sda.reads, 500); + assert_eq!(sda.sectors_read, 10000); + assert_eq!(sda.writes, 300); + assert_eq!(sda.sectors_written, 8000); + } + + #[test] + fn disk_io_rates_computes_throughput() { + let mut prev = BTreeMap::new(); + prev.insert( + "sda".to_string(), + DiskIOSample { + reads: 100, + sectors_read: 2000, + writes: 50, + sectors_written: 1000, + }, + ); + let mut curr = BTreeMap::new(); + curr.insert( + "sda".to_string(), + DiskIOSample { + reads: 120, // +20 ops + sectors_read: 4000, // +2000 sectors = 1,024,000 bytes + writes: 60, // +10 ops + sectors_written: 2000, // +1000 sectors = 512,000 bytes + }, + ); + let rates = disk_io_rates(&prev, &curr, 1.0); + let sda = &rates["sda"]; + assert_eq!(sda.read_bytes_per_sec, 2000 * 512); + assert_eq!(sda.write_bytes_per_sec, 1000 * 512); + assert_eq!(sda.read_iops, Some(20)); + assert_eq!(sda.write_iops, Some(10)); + } + + #[test] + fn read_hardware_parses_cpuinfo_and_dmi() { + let cpuinfo = b"\ +processor : 0 +model name : Test CPU 123 +cpu cores : 2 + +processor : 1 +model name : Test CPU 123 +cpu cores : 2 +"; + let hw = read_hardware_from( + Some(cpuinfo.as_slice()), + Some("Motherboard X"), + Some("Acme Corp"), + ); + assert_eq!(hw.cpu_model.as_deref(), Some("Test CPU 123")); + assert_eq!(hw.cpu_cores, Some(2)); + assert_eq!(hw.model.as_deref(), Some("Acme Corp Motherboard X")); + } + + #[test] + fn read_battery_graceful_on_missing_dir() { + let non_existent = Path::new("/tmp/mu_test_non_existent_power_supply_123"); + let res = read_battery_from(non_existent).unwrap(); + assert!(res.is_none()); + } + + #[test] + fn read_top_processes_parses_stat_and_sorts() { + let temp_dir = std::env::temp_dir().join(format!("mu_proc_test_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&temp_dir); + let p1 = temp_dir.join("101"); + let p2 = temp_dir.join("102"); + std::fs::create_dir_all(&p1).unwrap(); + std::fs::create_dir_all(&p2).unwrap(); + + // PID 101: comm with space "proc one", utime=10, stime=20 -> total=30, rss=500 + let stat1 = "101 (proc one) S 1 101 101 0 -1 4194304 100 0 0 0 10 20 0 0 20 0 1 0 1234 10000 500 18446744073709551615 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0"; + // PID 102: comm "proc_two", utime=100, stime=50 -> total=150, rss=200 + let stat2 = "102 (proc_two) R 1 102 102 0 -1 4194304 100 0 0 0 100 50 0 0 20 0 1 0 1234 10000 200 18446744073709551615 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0"; + + std::fs::write(p1.join("stat"), stat1).unwrap(); + std::fs::write(p2.join("stat"), stat2).unwrap(); + + let procs = read_top_processes_from(&temp_dir, 5).unwrap(); + let _ = std::fs::remove_dir_all(&temp_dir); + + assert_eq!(procs.len(), 2); + assert_eq!(procs[0].pid, 102); // 150 ticks > 30 ticks + assert_eq!(procs[0].name, "proc_two"); + assert_eq!(procs[1].pid, 101); + assert_eq!(procs[1].name, "proc one"); + } } diff --git a/src/tui/status.rs b/src/tui/status.rs index f507165..bc8ff78 100644 --- a/src/tui/status.rs +++ b/src/tui/status.rs @@ -23,6 +23,8 @@ pub struct StatusDashboard { pub dashboard: Dashboard, pub width: u16, pub readers: status::Readers, + pub show_cores: bool, + pub show_processes: bool, } impl StatusDashboard { @@ -32,6 +34,8 @@ impl StatusDashboard { dashboard: Dashboard::new(), width: DEFAULT_TERM, readers: status::Readers::real(), + show_cores: false, + show_processes: false, } } @@ -45,10 +49,21 @@ impl StatusDashboard { self.dashboard.tick(&mut self.readers); } - /// Handle a key — q/Q quits. Esc is NOT a quit key (M6 fix). + /// Handle a key — q/Q quits, c toggles CPU cores, p toggles top processes. pub fn handle_key(&mut self, key: crossterm::event::KeyCode) -> bool { use crossterm::event::KeyCode; - matches!(key, KeyCode::Char('q') | KeyCode::Char('Q')) + match key { + KeyCode::Char('q') | KeyCode::Char('Q') => true, + KeyCode::Char('c') | KeyCode::Char('C') => { + self.show_cores = !self.show_cores; + false + } + KeyCode::Char('p') | KeyCode::Char('P') => { + self.show_processes = !self.show_processes; + false + } + _ => false, + } } /// Handle a key event (with modifiers). Returns true if done. @@ -120,6 +135,25 @@ impl StatusDashboard { )); } + // CPU Cores (toggle with 'c') + if self.show_cores && !m.cpu_cores.is_empty() { + lines.push(Line::raw("")); + lines.push(Line::styled(" CPU Cores", styles::faint())); + for (i, &core_pct) in m.cpu_cores.iter().enumerate() { + let label = format!("Core {i}"); + let text = format!("{core_pct:.1}%"); + lines.push(metric_line( + &label, + LABEL_WIDTH, + core_pct, + styles::usage_color(core_pct), + &text, + &text, + width, + )); + } + } + // RAM if m.mem.total_kb == 0 { lines.push(Line::raw(format!(" {:<8} {}", "RAM", "unavailable"))); @@ -214,6 +248,24 @@ impl StatusDashboard { } } + // Top Processes (toggle with 'p') + if self.show_processes && !m.processes.is_empty() { + lines.push(Line::raw("")); + lines.push(Line::styled(" Top Processes", styles::faint())); + lines.push(Line::raw(format!( + " {:<7} {:<24} {:>10}", + "PID", "NAME", "MEM" + ))); + for p in &m.processes { + lines.push(Line::raw(format!( + " {:<7} {:<24} {:>10}", + p.pid, + truncate_width(&p.name, 24), + human_bytes(p.memory_bytes), + ))); + } + } + // Scan errors (m4 fix: truncate to terminal width like Go's truncateWidth) for scan_err in &m.scan_errors { lines.push(Line::styled( @@ -225,7 +277,10 @@ impl StatusDashboard { // Footer lines.push(Line::raw("")); lines.push(Line::raw("")); - lines.push(Line::styled(" q to quit", styles::faint())); + lines.push(Line::styled( + " q to quit · c cores · p processes", + styles::faint(), + )); frame.render_widget(Paragraph::new(ratatui::text::Text::from(lines)), area); } @@ -461,4 +516,24 @@ mod tests { let mut dash = StatusDashboard::new(); assert!(!dash.handle_key(crossterm::event::KeyCode::Char('x'))); } + + #[test] + fn toggle_cores_key() { + let mut dash = StatusDashboard::new(); + assert!(!dash.show_cores); + assert!(!dash.handle_key(crossterm::event::KeyCode::Char('c'))); + assert!(dash.show_cores); + assert!(!dash.handle_key(crossterm::event::KeyCode::Char('c'))); + assert!(!dash.show_cores); + } + + #[test] + fn toggle_processes_key() { + let mut dash = StatusDashboard::new(); + assert!(!dash.show_processes); + assert!(!dash.handle_key(crossterm::event::KeyCode::Char('p'))); + assert!(dash.show_processes); + assert!(!dash.handle_key(crossterm::event::KeyCode::Char('p'))); + assert!(!dash.show_processes); + } } diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 002d431..3251c56 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -63,6 +63,60 @@ fn status_json_flag() { serde_json::from_str::(&stdout(&o)).expect("status --json parse"); } +/// `mu status --json` emits extended fields `cpu_cores` and `disk_io`. +#[test] +fn status_json_extended_fields() { + let o = mu(&["status", "--json"]); + assert_eq!(o.status.code(), Some(0), "stderr: {}", stderr(&o)); + let v: serde_json::Value = serde_json::from_str(&stdout(&o)).expect("status --json parse"); + for key in [ + "cpu_percent", + "memory", + "disks", + "network", + "health", + "cpu_cores", + "disk_io", + ] { + assert!(v.get(key).is_some(), "missing {key} in {v}"); + } +} + +/// `mu status --watch` streams valid NDJSON lines containing required metrics. +#[test] +fn status_watch_ndjson_streaming() { + let mu_bin = env!("CARGO_BIN_EXE_mu"); + let mut child = Command::new(mu_bin) + .args(["status", "--watch", "--interval", "100ms"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn mu status --watch"); + + let stdout_pipe = child.stdout.take().expect("stdout pipe"); + let reader = std::io::BufReader::new(stdout_pipe); + let mut lines = Vec::new(); + for line in std::io::BufRead::lines(reader).take(2) { + let line = line.expect("read line from watch"); + let v: serde_json::Value = serde_json::from_str(&line).expect("parse NDJSON line"); + for key in ["cpu_percent", "memory", "disks", "network", "health"] { + assert!(v.get(key).is_some(), "missing {key} in {line}"); + } + lines.push(v); + } + assert_eq!(lines.len(), 2); + let _ = child.kill(); + let _ = child.wait(); +} + +/// `mu status --help` renders usage matching golden fixture. +#[test] +fn status_help_output() { + let o = mu(&["status", "--help"]); + assert_eq!(o.status.code(), Some(0)); + assert_eq!(stdout(&o), include_str!("golden/help-status.txt")); +} + /// `mu audit` piped → human report on stdout (Go falls back to report /// mode), exit code is the severity-driven 0/1/2 — never the old stub's 2+ /// or an arg error. diff --git a/tests/golden/help-status.txt b/tests/golden/help-status.txt index fafc149..39bca8f 100644 --- a/tests/golden/help-status.txt +++ b/tests/golden/help-status.txt @@ -8,14 +8,17 @@ Displays a real-time system health dashboard with visual metric bars. Use --json to output a structured JSON snapshot and exit. When piped (stdout is not a terminal), JSON is used automatically. +Use --watch to stream snapshots continuously as NDJSON. Press q or Ctrl+C to exit the dashboard. Usage: mu status [flags] Flags: - -h, --help help for status - --json Output JSON snapshot and exit + -h, --help help for status + --interval duration Refresh interval for --watch (default 1s) + --json Output JSON snapshot and exit + --watch Stream status snapshots continuously as NDJSON Global Flags: --debug Enable verbose debug logging From ccc0649acdf2d328b6a05a23be1bed83ffd80782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ba=20R=E1=BB=8Di?= Date: Wed, 14 Oct 2026 17:08:04 +0700 Subject: [PATCH 02/17] feat(mgmt): implement biometrics, self-update, safe removal, and integration (Phase 6) - Implement mu sudo (enable|disable|status) with pam_fprintd, backups, and lockout protection - Implement mu update with SHA-256 checksum verification against checksums.txt, atomic replacement, and --dry-run - Implement mu remove with safe FreeDesktop trash deletion via trash::SafeDelete and --dry-run sentinel verification - Implement mu completion --install for auto-installing shell completion scripts - Implement interactive whitelist editor (mu clean|optimize --whitelist) in Ratatui - Add biometric status hint and update notice banner to TUI main menu - Add golden help fixtures for sudo, update, and remove subcommands - Add comprehensive unit and integration acceptance tests (351 passed) --- Cargo.lock | 1 + Cargo.toml | 1 + src/cli.rs | 298 ++++++++++++++++++++- src/config.rs | 30 ++- src/main.rs | 6 + src/remove.rs | 199 ++++++++++++++ src/sudo.rs | 329 +++++++++++++++++++++++ src/tui/menu.rs | 27 ++ src/tui/mod.rs | 1 + src/tui/whitelist.rs | 206 +++++++++++++++ src/update.rs | 387 ++++++++++++++++++++++++++++ tests/cli_integration.rs | 98 +++++++ tests/golden/help-remove.exitcode | 1 + tests/golden/help-remove.stderr.txt | 0 tests/golden/help-remove.txt | 13 + tests/golden/help-sudo.exitcode | 1 + tests/golden/help-sudo.stderr.txt | 0 tests/golden/help-sudo.txt | 20 ++ tests/golden/help-update.exitcode | 1 + tests/golden/help-update.stderr.txt | 0 tests/golden/help-update.txt | 13 + tests/golden/help.txt | 3 + 22 files changed, 1622 insertions(+), 13 deletions(-) create mode 100644 src/remove.rs create mode 100644 src/sudo.rs create mode 100644 src/tui/whitelist.rs create mode 100644 src/update.rs create mode 100644 tests/golden/help-remove.exitcode create mode 100644 tests/golden/help-remove.stderr.txt create mode 100644 tests/golden/help-remove.txt create mode 100644 tests/golden/help-sudo.exitcode create mode 100644 tests/golden/help-sudo.stderr.txt create mode 100644 tests/golden/help-sudo.txt create mode 100644 tests/golden/help-update.exitcode create mode 100644 tests/golden/help-update.stderr.txt create mode 100644 tests/golden/help-update.txt diff --git a/Cargo.lock b/Cargo.lock index d3d7e01..b33b31a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -670,6 +670,7 @@ dependencies = [ "ratatui", "serde", "serde_json", + "sha2", "thiserror 2.0.20", "time", "toml", diff --git a/Cargo.toml b/Cargo.toml index e10abe4..005484f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ ratatui = "0.30" crossterm = "0.29" nix = { version = "0.31", features = ["fs", "process", "user"] } time = { version = "0.3", features = ["local-offset", "formatting"] } +sha2 = "0.10" [profile.release] # Go built with `-s -w` — match by stripping symbols from the release binary. diff --git a/src/cli.rs b/src/cli.rs index e130f4d..99890a7 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -44,8 +44,11 @@ Available Commands: completion Generate the autocompletion script for the specified shell help Help about any command optimize Run system maintenance: apt autoremove, journal vacuum, cache refresh + remove Safely uninstall mu and remove configuration, logs, and cache status Live system dashboard: CPU, RAM, disk, network, health score + sudo Configure fingerprint authentication for sudo (pam_fprintd) uninstall Remove apps and all their config, cache, and data remnants + update Check for updates and self-update to the latest release Flags: --debug Enable verbose debug logging @@ -203,6 +206,61 @@ Global Flags: --debug Enable verbose debug logging "; +const REMOVE_HELP: &str = "\ +Safely uninstall mu and remove configuration, logs, and cache. +All deletions route strictly through trash::SafeDelete. + +Usage: + mu remove [flags] + +Flags: + --dry-run Preview actions without making changes + -h, --help help for remove + -y, --yes Confirm removal without prompting + +Global Flags: + --debug Enable verbose debug logging +"; + +const SUDO_HELP: &str = "\ +Configure fingerprint authentication for sudo (pam_fprintd). +Enables or disables biometric authentication in /etc/pam.d/sudo with +automatic backup creation to prevent user lockout. + +Usage: + mu sudo [command] [flags] + +Available Commands: + disable Disable fingerprint authentication for sudo + enable Enable fingerprint authentication for sudo + status Show current fingerprint authentication status + +Flags: + --dry-run Preview actions without making changes + -h, --help help for sudo + +Global Flags: + --debug Enable verbose debug logging + +Use \"mu sudo [command] --help\" for more information about a command. +"; + +const UPDATE_HELP: &str = "\ +Check for updates and self-update to the latest release. +Verifies SHA-256 cryptographic checksums before replacing the binary. + +Usage: + mu update [flags] + +Flags: + --dry-run Check for updates without downloading or installing + --force Force reinstall or re-download even if already up-to-date + -h, --help help for update + +Global Flags: + --debug Enable verbose debug logging +"; + /// Cobra's "usage" template — shown on errors and `help `. Same as /// ROOT_HELP but without the `-h`/`-v` flag lines (cobra's UsageTemplate /// omits the help/version flags that HelpTemplate includes). @@ -218,8 +276,11 @@ Available Commands: completion Generate the autocompletion script for the specified shell help Help about any command optimize Run system maintenance: apt autoremove, journal vacuum, cache refresh + remove Safely uninstall mu and remove configuration, logs, and cache status Live system dashboard: CPU, RAM, disk, network, health score + sudo Configure fingerprint authentication for sudo (pam_fprintd) uninstall Remove apps and all their config, cache, and data remnants + update Check for updates and self-update to the latest release Flags: --debug Enable verbose debug logging @@ -385,8 +446,11 @@ const REAL_COMMANDS: &[&str] = &[ "audit", "clean", "optimize", + "remove", "status", + "sudo", "uninstall", + "update", ]; /// Per-command help text for the known real commands. fn cmd_help(name: &str) -> Option<&'static str> { @@ -395,8 +459,11 @@ fn cmd_help(name: &str) -> Option<&'static str> { "audit" => Some(AUDIT_HELP), "clean" => Some(CLEAN_HELP), "optimize" => Some(OPTIMIZE_HELP), + "remove" => Some(REMOVE_HELP), "status" => Some(STATUS_HELP), + "sudo" => Some(SUDO_HELP), "uninstall" => Some(UNINSTALL_HELP), + "update" => Some(UPDATE_HELP), _ => None, } } @@ -433,11 +500,13 @@ enum Command { dry_run: bool, include: Vec, yes: bool, + whitelist: bool, }, Optimize { dry_run: bool, skip: Vec, yes: bool, + whitelist: bool, }, Audit { report: bool, @@ -448,6 +517,20 @@ enum Command { Uninstall { dry_run: bool, }, + Remove { + dry_run: bool, + yes: bool, + }, + Sudo { + command: crate::sudo::SudoCommand, + dry_run: bool, + pam_path: Option, + }, + Update { + dry_run: bool, + force: bool, + api_url: Option, + }, } // --------------------------------------------------------------------------- @@ -507,7 +590,21 @@ pub fn run() -> ! { std::process::exit(code); } Command::Status { json } => std::process::exit(status::run(json)), - Command::Optimize { dry_run, skip, yes } => { + Command::Optimize { + dry_run, + skip, + yes, + whitelist, + } => { + if whitelist { + if std::io::stdout().is_terminal() { + let _ = crate::tui::whitelist::run_whitelist_tui(); + std::process::exit(0); + } else { + println!("Whitelist configuration: ~/.config/mu/config.toml"); + std::process::exit(0); + } + } let code = match optimize::run(&optimize::Options { dry_run, debug, @@ -526,7 +623,17 @@ pub fn run() -> ! { dry_run, include, yes, + whitelist, } => { + if whitelist { + if std::io::stdout().is_terminal() { + let _ = crate::tui::whitelist::run_whitelist_tui(); + std::process::exit(0); + } else { + println!("Whitelist configuration: ~/.config/mu/config.toml"); + std::process::exit(0); + } + } let code = match clean::run(&clean::Options { dry_run, debug, @@ -556,6 +663,41 @@ pub fn run() -> ! { Command::Uninstall { dry_run } => { std::process::exit(uninstall::run(&uninstall::Options { dry_run, debug })) } + Command::Remove { dry_run, yes } => { + let code = crate::remove::run(&crate::remove::Options { + dry_run, + auto_yes: yes, + debug, + }); + std::process::exit(code); + } + Command::Sudo { + command, + dry_run, + pam_path, + } => { + let code = crate::sudo::run(&crate::sudo::Options { + command, + dry_run, + debug, + pam_path, + }); + std::process::exit(code); + } + Command::Update { + dry_run, + force, + api_url, + } => { + let code = crate::update::run(&crate::update::Options { + dry_run, + force, + debug, + target_bin: None, + api_url, + }); + std::process::exit(code); + } } } } @@ -676,20 +818,41 @@ const F_NODESC: FlagSpec = FlagSpec { takes_value: false, display: "--no-descriptions", }; +const F_FORCE: FlagSpec = FlagSpec { + long: "force", + short: None, + takes_value: false, + display: "--force", +}; +const F_INSTALL: FlagSpec = FlagSpec { + long: "install", + short: None, + takes_value: false, + display: "--install", +}; +const F_WHITELIST: FlagSpec = FlagSpec { + long: "whitelist", + short: None, + takes_value: false, + display: "--whitelist", +}; const ROOT_FLAGS: &[FlagSpec] = &[F_DEBUG, F_HELP, F_VERSION]; const HELP_FLAGS: &[FlagSpec] = &[F_DEBUG, F_HELP]; -const COMPLETION_FLAGS: &[FlagSpec] = &[F_DEBUG, F_HELP]; -const SHELL_FLAGS: &[FlagSpec] = &[F_DEBUG, F_HELP, F_NODESC]; +const COMPLETION_FLAGS: &[FlagSpec] = &[F_DEBUG, F_HELP, F_INSTALL]; +const SHELL_FLAGS: &[FlagSpec] = &[F_DEBUG, F_HELP, F_NODESC, F_INSTALL]; fn cmd_flagset(name: &str) -> &'static [FlagSpec] { match name { "analyze" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_JSON], "status" => &[F_DEBUG, F_HELP, F_JSON], - "clean" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_YES, F_INCLUDE], - "optimize" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_YES, F_SKIP], + "clean" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_YES, F_INCLUDE, F_WHITELIST], + "optimize" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_YES, F_SKIP, F_WHITELIST], "audit" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_JSON, F_REPORT, F_INCLUDE], + "remove" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_YES], + "sudo" => &[F_DEBUG, F_HELP, F_DRY_RUN], "uninstall" => &[F_DEBUG, F_HELP, F_DRY_RUN], + "update" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_FORCE], _ => &[], } } @@ -1016,11 +1179,13 @@ fn exec_subcommand(name: &str, args: &[String]) -> Outcome { dry_run: p.get("dry-run"), include: p.vals("include"), yes: p.get("yes"), + whitelist: p.get("whitelist"), }, "optimize" => Command::Optimize { dry_run: p.get("dry-run"), skip: p.vals("skip"), yes: p.get("yes"), + whitelist: p.get("whitelist"), }, "audit" => Command::Audit { report: p.get("report"), @@ -1031,6 +1196,30 @@ fn exec_subcommand(name: &str, args: &[String]) -> Outcome { "uninstall" => Command::Uninstall { dry_run: p.get("dry-run"), }, + "remove" => Command::Remove { + dry_run: p.get("dry-run"), + yes: p.get("yes"), + }, + "sudo" => { + let subcmd = match p.positionals.first().map(|s| s.as_str()) { + Some("enable") => crate::sudo::SudoCommand::Enable, + Some("disable") => crate::sudo::SudoCommand::Disable, + Some("status") | None => crate::sudo::SudoCommand::Status, + Some(other) => { + return perr(format!("unknown command \"{other}\" for \"mu sudo\"\n")); + } + }; + Command::Sudo { + command: subcmd, + dry_run: p.get("dry-run"), + pam_path: None, + } + } + "update" => Command::Update { + dry_run: p.get("dry-run"), + force: p.get("force"), + api_url: None, + }, _ => unreachable!(), }; Outcome::Run { @@ -1088,6 +1277,19 @@ fn exec_completion(args: &[String]) -> Outcome { if p.get("help") { return Outcome::Stdout(shell_help(&shell).to_string()); } + if p.get("install") { + match install_completion(&shell) { + Ok(path) => { + return Outcome::Stdout(format!( + "Installed completion for {shell} to {}\n", + path.display() + )); + } + Err(e) => { + return perr(format!("error installing completion for {shell}: {e}\n")); + } + } + } if let Some(x) = p.positionals.first() { return perr(format!( "unknown command \"{x}\" for \"mu completion {shell}\"\n" @@ -1097,7 +1299,29 @@ fn exec_completion(args: &[String]) -> Outcome { } match flag_parse(args, COMPLETION_FLAGS) { Err(o) => o, - Ok(_) => Outcome::Stdout(COMPLETION_HELP.to_string()), + Ok(p) => { + if p.get("install") { + let shell = p.positionals.first().map(|s| s.as_str()).unwrap_or("bash"); + if is_shell(shell) { + match install_completion(shell) { + Ok(path) => { + return Outcome::Stdout(format!( + "Installed completion for {shell} to {}\n", + path.display() + )); + } + Err(e) => { + return perr(format!("error installing completion for {shell}: {e}\n")); + } + } + } else { + return perr(format!( + "unknown shell \"{shell}\" for completion --install\n" + )); + } + } + Outcome::Stdout(COMPLETION_HELP.to_string()) + } } } @@ -1119,7 +1343,7 @@ fn shell_help(shell: &str) -> &'static str { /// Generate a minimal shell completion script. The content differs from /// cobra's (accepted divergence — functional parity, not byte parity). fn completion_script(shell: &str) -> String { - let cmds = "audit clean optimize status uninstall"; + let cmds = "analyze audit clean completion help optimize remove status sudo uninstall update"; match shell { "bash" => format!( "# bash completion for mu\n\ @@ -1153,6 +1377,51 @@ Register-ArgumentCompleter -Native -CommandName mu -ScriptBlock {{\n\ } } +fn install_completion(shell: &str) -> std::result::Result { + let script = completion_script(shell); + let target = match shell { + "bash" => { + let sys_dir = std::path::Path::new("/etc/bash_completion.d"); + if sys_dir.exists() + && nix::unistd::access(sys_dir, nix::unistd::AccessFlags::W_OK).is_ok() + { + sys_dir.join("mu") + } else { + let user_dir = crate::xdg::data_home().join("bash-completion/completions"); + std::fs::create_dir_all(&user_dir).map_err(|e| { + format!("failed to create directory {}: {}", user_dir.display(), e) + })?; + user_dir.join("mu") + } + } + "zsh" => { + let home = crate::xdg::home_dir(); + let zsh_dir = home.join(".zsh/completion"); + std::fs::create_dir_all(&zsh_dir) + .map_err(|e| format!("failed to create directory {}: {}", zsh_dir.display(), e))?; + zsh_dir.join("_mu") + } + "fish" => { + let home = crate::xdg::home_dir(); + let fish_dir = home.join(".config/fish/completions"); + std::fs::create_dir_all(&fish_dir) + .map_err(|e| format!("failed to create directory {}: {}", fish_dir.display(), e))?; + fish_dir.join("mu.fish") + } + _ => return Err(format!("unsupported shell for auto-install: {shell}")), + }; + + std::fs::write(&target, script).map_err(|e| { + format!( + "failed to write completion script to {}: {}", + target.display(), + e + ) + })?; + + Ok(target) +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -1873,4 +2142,19 @@ mod tests { include_str!("../tests/golden/help-uninstall.txt") ); } + + #[test] + fn golden_remove_help_byte_exact() { + assert_eq!(REMOVE_HELP, include_str!("../tests/golden/help-remove.txt")); + } + + #[test] + fn golden_sudo_help_byte_exact() { + assert_eq!(SUDO_HELP, include_str!("../tests/golden/help-sudo.txt")); + } + + #[test] + fn golden_update_help_byte_exact() { + assert_eq!(UPDATE_HELP, include_str!("../tests/golden/help-update.txt")); + } } diff --git a/src/config.rs b/src/config.rs index 4a58126..278179c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,9 +7,9 @@ //! commands, including `--dry-run`. Unknown keys are rejected via //! `deny_unknown_fields` (the serde equivalent of BurntSushi's `Undecoded()`). -use std::path::Path; +use std::path::{Path, PathBuf}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use crate::error::{Error, Result, msg}; use crate::{paths, xdg}; @@ -17,7 +17,7 @@ use crate::{paths, xdg}; const DEFAULT_WHITELIST_TOML: &str = include_str!("default-whitelist.toml"); /// Holds path protection rules and skip lists. -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct Whitelist { #[serde(default)] @@ -28,7 +28,7 @@ pub struct Whitelist { pub optimize_skip: OptimizeSkip, } -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct ProtectedPaths { #[serde(default)] @@ -37,14 +37,14 @@ pub struct ProtectedPaths { pub protect_running_kernel: bool, } -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct CacheSkip { #[serde(default)] pub dirs: Vec, } -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct OptimizeSkip { #[serde(default)] @@ -104,6 +104,24 @@ pub fn load_whitelist_from(config_home: &Path) -> Result { Ok(wl) } +/// Saves whitelist overrides to `$XDG_CONFIG_HOME/mu/config.toml`. +pub fn save_whitelist(wl: &Whitelist) -> Result { + save_whitelist_to(&xdg::config_home(), wl) +} + +/// Testable core: saves whitelist to `/mu/config.toml`. +pub fn save_whitelist_to(config_home: &Path, wl: &Whitelist) -> Result { + let mu_dir = config_home.join("mu"); + if !mu_dir.exists() { + std::fs::create_dir_all(&mu_dir)?; + } + let user_cfg = mu_dir.join("config.toml"); + let serialized = toml::to_string_pretty(wl) + .map_err(|e| Error::Msg(format!("failed to serialize config: {e}")))?; + std::fs::write(&user_cfg, serialized)?; + Ok(user_cfg) +} + fn validate_whitelist_override(override_cfg: &Whitelist) -> Result<()> { for path in &override_cfg.protected_paths.system { if !Path::new(path).is_absolute() { diff --git a/src/main.rs b/src/main.rs index 01d688f..88af9cb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,16 +19,22 @@ mod optimize; #[allow(dead_code)] mod paths; #[allow(dead_code)] +mod remove; +#[allow(dead_code)] mod runner; mod runtext; #[allow(dead_code)] mod size; mod status; #[allow(dead_code)] +mod sudo; +#[allow(dead_code)] mod trash; #[allow(dead_code)] mod uninstall; #[allow(dead_code)] +mod update; +#[allow(dead_code)] mod whitelist; mod xdg; diff --git a/src/remove.rs b/src/remove.rs new file mode 100644 index 0000000..282b9a7 --- /dev/null +++ b/src/remove.rs @@ -0,0 +1,199 @@ +//! Safe self-removal — uninstalls `mu`, removing configuration, logs, +//! cache, shell completions, desktop entry, and executable binary. +//! +//! Enforces safety invariant: all user deletions route strictly through +//! `trash::safe_delete` (never raw `rm`). Supports `--dry-run` and `--yes`. + +use std::path::{Path, PathBuf}; + +use crate::paths; +use crate::trash; +use crate::xdg; + +#[derive(Clone, Debug, Default)] +pub struct Options { + pub dry_run: bool, + pub auto_yes: bool, + pub debug: bool, +} + +pub struct RemovableItem { + pub label: &'static str, + pub path: PathBuf, +} + +/// Discovers all existing mu files and directories across the system. +pub fn discover_components() -> Vec { + let mut items = Vec::new(); + + // 1. Config directory + let config_dir = xdg::config_home().join("mu"); + if paths::path_exists(&config_dir) { + items.push(RemovableItem { + label: "Configuration directory", + path: config_dir, + }); + } + + // 2. Cache directory + let cache_dir = xdg::cache_home().join("mu"); + if paths::path_exists(&cache_dir) { + items.push(RemovableItem { + label: "Cache directory", + path: cache_dir, + }); + } + + // 3. Data directory (operations.log, etc.) + let data_dir = xdg::data_home().join("mu"); + if paths::path_exists(&data_dir) { + items.push(RemovableItem { + label: "Data & logs directory", + path: data_dir, + }); + } + + let home = xdg::home_dir(); + if !home.as_os_str().is_empty() { + // 4. Desktop entry + let desktop = home.join(".local/share/applications/mu.desktop"); + if paths::path_exists(&desktop) { + items.push(RemovableItem { + label: "Desktop launcher", + path: desktop, + }); + } + + // 5. Completions + let bash_comp = home.join(".local/share/bash-completion/completions/mu"); + if paths::path_exists(&bash_comp) { + items.push(RemovableItem { + label: "Bash completion", + path: bash_comp, + }); + } + + let zsh_comp = home.join(".zsh/completion/_mu"); + if paths::path_exists(&zsh_comp) { + items.push(RemovableItem { + label: "Zsh completion", + path: zsh_comp, + }); + } + + let zsh_site = home.join(".local/share/zsh/site-functions/_mu"); + if paths::path_exists(&zsh_site) { + items.push(RemovableItem { + label: "Zsh site completion", + path: zsh_site, + }); + } + + let fish_comp = home.join(".config/fish/completions/mu.fish"); + if paths::path_exists(&fish_comp) { + items.push(RemovableItem { + label: "Fish completion", + path: fish_comp, + }); + } + + // 6. User local binary + let local_bin = home.join(".local/bin/mu"); + if paths::path_exists(&local_bin) { + items.push(RemovableItem { + label: "Binary (~/.local/bin/mu)", + path: local_bin, + }); + } + } + + // 7. System completion / binary if writable and exists + let sys_bash = Path::new("/etc/bash_completion.d/mu"); + if paths::path_exists(sys_bash) && !paths::is_protected(sys_bash) { + items.push(RemovableItem { + label: "System bash completion", + path: sys_bash.to_path_buf(), + }); + } + + items +} + +pub fn run(opts: &Options) -> i32 { + let components = discover_components(); + + if components.is_empty() { + println!("No mu components found to remove."); + return 0; + } + + println!("Discovered mu components to remove:"); + for item in &components { + println!(" • {:<26} {}", item.label, item.path.display()); + } + + if opts.dry_run { + println!(); + println!("[dry-run] DRY RUN: no files were removed."); + println!("[dry-run] All components will be moved to trash via trash::SafeDelete."); + return 0; + } + + if !opts.auto_yes { + println!(); + println!("Non-interactive: pass --yes to confirm removal of mu."); + println!("Aborted."); + return 0; + } + + let mut failed = 0; + for item in &components { + if paths::is_protected(&item.path) { + eprintln!( + "warning: skipping protected system path: {}", + item.path.display() + ); + continue; + } + + println!("Removing {}...", item.path.display()); + if let Err(e) = trash::safe_delete(&item.path, false) { + eprintln!("error removing {}: {}", item.path.display(), e); + failed += 1; + } + } + + if failed > 0 { + eprintln!("Completed with {failed} failure(s)."); + 1 + } else { + println!(); + println!("✨ mu successfully removed. All data was safely moved to trash."); + 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_remove_dry_run_leaves_files_untouched() { + let tmp = std::env::temp_dir().join(format!("mu-remove-dry-{}", std::process::id())); + let _ = std::fs::create_dir_all(&tmp); + let test_file = tmp.join("sentinel.dat"); + std::fs::write(&test_file, b"safe data").unwrap(); + + let opts = Options { + dry_run: true, + auto_yes: false, + debug: false, + }; + + let code = run(&opts); + assert_eq!(code, 0); + assert!(test_file.exists(), "sentinel must remain untouched"); + + let _ = std::fs::remove_dir_all(&tmp); + } +} diff --git a/src/sudo.rs b/src/sudo.rs new file mode 100644 index 0000000..4c6835d --- /dev/null +++ b/src/sudo.rs @@ -0,0 +1,329 @@ +//! Biometric sudo configurator — port of Mole's Touch ID manager mapped to Ubuntu. +//! +//! Configures Linux PAM via `pam_fprintd.so` in `/etc/pam.d/sudo`. +//! Supports `status`, `enable`, and `disable` with `--dry-run` and automatic +//! backup generation (`.mu-bak`) to prevent lockout. + +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::oplog; + +const DEFAULT_PAM_SUDO: &str = "/etc/pam.d/sudo"; +const FPRINTD_PAM_LINE: &str = "auth sufficient pam_fprintd.so"; + +const PAM_FPRINTD_PATHS: &[&str] = &[ + "/usr/lib/x86_64-linux-gnu/security/pam_fprintd.so", + "/lib/x86_64-linux-gnu/security/pam_fprintd.so", + "/usr/lib/aarch64-linux-gnu/security/pam_fprintd.so", + "/lib/aarch64-linux-gnu/security/pam_fprintd.so", + "/usr/lib/security/pam_fprintd.so", + "/lib/security/pam_fprintd.so", +]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SudoCommand { + Enable, + Disable, + Status, +} + +#[derive(Clone, Debug)] +pub struct Options { + pub command: SudoCommand, + pub dry_run: bool, + pub debug: bool, + pub pam_path: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StatusInfo { + pub enabled: bool, + pub module_installed: bool, + pub pam_file_exists: bool, + pub pam_path: PathBuf, +} + +pub fn is_module_installed() -> bool { + PAM_FPRINTD_PATHS.iter().any(|p| Path::new(p).exists()) +} + +pub fn resolve_pam_path(custom: Option<&Path>) -> PathBuf { + if let Some(p) = custom { + return p.to_path_buf(); + } + if let Ok(env_path) = std::env::var("MU_PAM_FILE") { + return PathBuf::from(env_path); + } + PathBuf::from(DEFAULT_PAM_SUDO) +} + +pub fn get_status(pam_path: &Path) -> StatusInfo { + let pam_file_exists = pam_path.exists(); + let module_installed = is_module_installed(); + let mut enabled = false; + + if pam_file_exists && let Ok(content) = fs::read_to_string(pam_path) { + for line in content.lines() { + let trimmed = line.trim(); + if !trimmed.starts_with('#') && trimmed.contains("pam_fprintd.so") { + enabled = true; + break; + } + } + } + + StatusInfo { + enabled, + module_installed, + pam_file_exists, + pam_path: pam_path.to_path_buf(), + } +} + +pub fn enable(pam_path: &Path, dry_run: bool) -> Result<(), String> { + let status = get_status(pam_path); + if !status.pam_file_exists { + return Err(format!("PAM file does not exist: {}", pam_path.display())); + } + if status.enabled { + println!("Fingerprint authentication for sudo is already enabled."); + return Ok(()); + } + + let content = fs::read_to_string(pam_path) + .map_err(|e| format!("cannot read PAM file {}: {}", pam_path.display(), e))?; + + // Determine insertion position: before `@include common-auth` if present, + // otherwise before first auth or at the end. + let mut new_lines = Vec::new(); + let mut inserted = false; + + for line in content.lines() { + if !inserted && line.trim().starts_with("@include common-auth") { + new_lines.push(FPRINTD_PAM_LINE.to_string()); + inserted = true; + } + new_lines.push(line.to_string()); + } + + if !inserted { + new_lines.insert(0, FPRINTD_PAM_LINE.to_string()); + } + + let new_content = new_lines.join("\n") + "\n"; + let backup_path = pam_path.with_extension("mu-bak"); + + if dry_run { + println!("[dry-run] Would enable fingerprint authentication for sudo"); + println!("[dry-run] Target: {}", pam_path.display()); + println!("[dry-run] Backup: {}", backup_path.display()); + println!("[dry-run] + {FPRINTD_PAM_LINE}"); + if !status.module_installed { + println!("[dry-run] Note: pam_fprintd.so is not currently installed on this system"); + } + oplog::log_outcome("sudo", "enable", "dry-run"); + return Ok(()); + } + + // Backup original + fs::write(&backup_path, &content) + .map_err(|e| format!("failed to create backup {}: {}", backup_path.display(), e))?; + + // Write new content + if let Err(e) = fs::write(pam_path, &new_content) { + return Err(format!( + "cannot write to {} (permission denied? run with sudo): {}", + pam_path.display(), + e + )); + } + + // Safety verification: file must not be empty and must be readable + match fs::read_to_string(pam_path) { + Ok(verified) if verified.contains("pam_fprintd.so") => { + println!("Fingerprint authentication enabled for sudo."); + println!("Backup created at: {}", backup_path.display()); + oplog::log_outcome("sudo", "enable", "success"); + Ok(()) + } + _ => { + // Rollback + let _ = fs::write(pam_path, content); + Err("verification failed after editing PAM file; rolled back changes".to_string()) + } + } +} + +pub fn disable(pam_path: &Path, dry_run: bool) -> Result<(), String> { + let status = get_status(pam_path); + if !status.pam_file_exists { + return Err(format!("PAM file does not exist: {}", pam_path.display())); + } + if !status.enabled { + println!("Fingerprint authentication for sudo is already disabled."); + return Ok(()); + } + + let content = fs::read_to_string(pam_path) + .map_err(|e| format!("cannot read PAM file {}: {}", pam_path.display(), e))?; + + let mut new_lines = Vec::new(); + let mut removed_lines = Vec::new(); + + for line in content.lines() { + if line.contains("pam_fprintd.so") { + removed_lines.push(line.to_string()); + } else { + new_lines.push(line.to_string()); + } + } + + let new_content = new_lines.join("\n") + "\n"; + let backup_path = pam_path.with_extension("mu-bak"); + + if dry_run { + println!("[dry-run] Would disable fingerprint authentication for sudo"); + println!("[dry-run] Target: {}", pam_path.display()); + println!("[dry-run] Backup: {}", backup_path.display()); + for line in &removed_lines { + println!("[dry-run] - {line}"); + } + oplog::log_outcome("sudo", "disable", "dry-run"); + return Ok(()); + } + + // Backup original + fs::write(&backup_path, &content) + .map_err(|e| format!("failed to create backup {}: {}", backup_path.display(), e))?; + + // Write new content + if let Err(e) = fs::write(pam_path, &new_content) { + return Err(format!( + "cannot write to {} (permission denied? run with sudo): {}", + pam_path.display(), + e + )); + } + + println!("Fingerprint authentication disabled for sudo."); + println!("Backup created at: {}", backup_path.display()); + oplog::log_outcome("sudo", "disable", "success"); + Ok(()) +} + +pub fn run(opts: &Options) -> i32 { + let pam_path = resolve_pam_path(opts.pam_path.as_deref()); + + match opts.command { + SudoCommand::Status => { + let status = get_status(&pam_path); + let state = if status.enabled { + "enabled" + } else { + "disabled" + }; + println!("Fingerprint authentication for sudo: {state}"); + println!("PAM target: {}", status.pam_path.display()); + if status.module_installed { + println!("Module pam_fprintd.so: installed"); + } else { + println!( + "Module pam_fprintd.so: not found (install with: sudo apt install libpam-fprintd)" + ); + } + 0 + } + SudoCommand::Enable => match enable(&pam_path, opts.dry_run) { + Ok(()) => 0, + Err(e) => { + eprintln!("error: {e}"); + 1 + } + }, + SudoCommand::Disable => match disable(&pam_path, opts.dry_run) { + Ok(()) => 0, + Err(e) => { + eprintln!("error: {e}"); + 1 + } + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_PAM: &str = "\ +#%PAM-1.0 +session required pam_limits.so +session required pam_env.so readenv=1 user_readenv=0 + +@include common-auth +@include common-account +@include common-session-noninteractive +"; + + #[test] + fn test_status_disabled_on_sample() { + let tmp = std::env::temp_dir().join(format!("mu-sudo-test-{}", std::process::id())); + let _ = fs::create_dir_all(&tmp); + let pam_file = tmp.join("sudo"); + fs::write(&pam_file, SAMPLE_PAM).unwrap(); + + let status = get_status(&pam_file); + assert!(!status.enabled); + assert!(status.pam_file_exists); + + let _ = fs::remove_dir_all(&tmp); + } + + #[test] + fn test_enable_and_disable_lifecycle() { + let tmp = std::env::temp_dir().join(format!("mu-sudo-lifecycle-{}", std::process::id())); + let _ = fs::create_dir_all(&tmp); + let pam_file = tmp.join("sudo"); + fs::write(&pam_file, SAMPLE_PAM).unwrap(); + + // 1. Enable dry-run + let res = enable(&pam_file, true); + assert!(res.is_ok()); + let status = get_status(&pam_file); + assert!(!status.enabled, "dry-run must not modify PAM file"); + + // 2. Enable real + let res = enable(&pam_file, false); + assert!(res.is_ok()); + let status = get_status(&pam_file); + assert!(status.enabled, "real enable must enable fingerprint auth"); + + // Backup must exist + let backup = pam_file.with_extension("mu-bak"); + assert!(backup.exists(), "backup file must exist"); + let backup_content = fs::read_to_string(&backup).unwrap(); + assert!(!backup_content.contains("pam_fprintd.so")); + + // Content verification: line inserted before @include common-auth + let modified = fs::read_to_string(&pam_file).unwrap(); + let fprintd_idx = modified.find("pam_fprintd.so").unwrap(); + let common_auth_idx = modified.find("@include common-auth").unwrap(); + assert!(fprintd_idx < common_auth_idx); + + // 3. Re-enable when already enabled -> no-op Ok + let res = enable(&pam_file, false); + assert!(res.is_ok()); + + // 4. Disable dry-run + let res = disable(&pam_file, true); + assert!(res.is_ok()); + assert!(get_status(&pam_file).enabled, "dry run must not disable"); + + // 5. Disable real + let res = disable(&pam_file, false); + assert!(res.is_ok()); + assert!(!get_status(&pam_file).enabled); + + let _ = fs::remove_dir_all(&tmp); + } +} diff --git a/src/tui/menu.rs b/src/tui/menu.rs index 9b8d918..c4e27ef 100644 --- a/src/tui/menu.rs +++ b/src/tui/menu.rs @@ -232,6 +232,33 @@ impl MainMenu { styles::bold_primary(), )); + // Biometric hint + let sudo_status = crate::sudo::get_status(&crate::sudo::resolve_pam_path(None)); + if sudo_status.enabled { + lines.push(Line::styled( + " [Biometrics: Enabled (pam_fprintd)]", + styles::faint(), + )); + } else if sudo_status.module_installed { + lines.push(Line::styled( + " [Biometrics: Available (run 'mu sudo enable')]", + styles::faint(), + )); + } + + // Update notice if cached check shows newer version + let cache_check = crate::xdg::cache_home().join("mu/last_version_check.json"); + if let Ok(content) = std::fs::read_to_string(&cache_check) + && let Ok(v) = serde_json::from_str::(&content) + && let Some(latest) = v.get("latest").and_then(|s| s.as_str()) + && crate::update::is_newer_version(latest, crate::update::current_version()) + { + lines.push(Line::styled( + format!(" ★ Update available: {latest} (run 'mu update')"), + styles::bold_primary(), + )); + } + // Banner (transient done/error summary) if !self.banner.is_empty() { let style = if self.banner_err { diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 2d5482c..1e7b404 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -22,6 +22,7 @@ pub mod run; pub mod status; pub mod styles; pub mod uninstall; +pub mod whitelist; use std::io::IsTerminal; diff --git a/src/tui/whitelist.rs b/src/tui/whitelist.rs new file mode 100644 index 0000000..f0f19f0 --- /dev/null +++ b/src/tui/whitelist.rs @@ -0,0 +1,206 @@ +//! Interactive Whitelist Editor — Ratatui widget to view and manage +//! protected paths, cache skip patterns, and optimize skip steps. + +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::{Line, Text}; +use ratatui::widgets::Paragraph; + +use super::styles; +use crate::config::{self, Whitelist}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WhitelistTab { + ProtectedPaths, + CacheSkip, + OptimizeSkip, +} + +pub struct WhitelistTui { + pub tab: WhitelistTab, + pub cursor: usize, + pub config: Whitelist, + pub modified: bool, + pub status_msg: Option, +} + +impl WhitelistTui { + pub fn new() -> Self { + let config = config::load_whitelist().unwrap_or_default(); + Self { + tab: WhitelistTab::ProtectedPaths, + cursor: 0, + config, + modified: false, + status_msg: None, + } + } + + pub fn current_items(&self) -> &[String] { + match self.tab { + WhitelistTab::ProtectedPaths => &self.config.protected_paths.system, + WhitelistTab::CacheSkip => &self.config.cache_skip.dirs, + WhitelistTab::OptimizeSkip => &self.config.optimize_skip.steps, + } + } + + pub fn handle_key_event(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => true, + KeyCode::Tab => { + self.tab = match self.tab { + WhitelistTab::ProtectedPaths => WhitelistTab::CacheSkip, + WhitelistTab::CacheSkip => WhitelistTab::OptimizeSkip, + WhitelistTab::OptimizeSkip => WhitelistTab::ProtectedPaths, + }; + self.cursor = 0; + false + } + KeyCode::Up | KeyCode::Char('k') => { + if self.cursor > 0 { + self.cursor -= 1; + } + false + } + KeyCode::Down | KeyCode::Char('j') => { + let len = self.current_items().len(); + if len > 0 && self.cursor < len - 1 { + self.cursor += 1; + } + false + } + KeyCode::Char('s') => { + match config::save_whitelist(&self.config) { + Ok(path) => { + self.status_msg = Some(format!("Saved to {}", path.display())); + self.modified = false; + } + Err(e) => { + self.status_msg = Some(format!("Error saving: {e}")); + } + } + false + } + _ => false, + } + } + + pub fn render(&self, frame: &mut Frame, area: Rect) { + let mut lines = Vec::new(); + + lines.push(Line::raw("")); + lines.push(Line::styled( + " Whitelist Configuration Editor", + styles::bold_primary(), + )); + + // Tab bar + let tab_prot = if self.tab == WhitelistTab::ProtectedPaths { + "[ 1. Protected Paths ]" + } else { + " 1. Protected Paths " + }; + let tab_cache = if self.tab == WhitelistTab::CacheSkip { + "[ 2. Cache Skip ]" + } else { + " 2. Cache Skip " + }; + let tab_opt = if self.tab == WhitelistTab::OptimizeSkip { + "[ 3. Optimize Skip ]" + } else { + " 3. Optimize Skip " + }; + + lines.push(Line::styled( + format!(" {tab_prot} {tab_cache} {tab_opt}"), + Style::default(), + )); + lines.push(Line::raw("")); + + // Status / notice + if let Some(msg) = &self.status_msg { + lines.push(Line::styled(format!(" {msg}"), styles::bold_primary())); + lines.push(Line::raw("")); + } + + // List + let items = self.current_items(); + if items.is_empty() { + lines.push(Line::styled( + " (no entries in this category)", + styles::faint(), + )); + } else { + for (idx, item) in items.iter().enumerate() { + let prefix = if idx == self.cursor { "▶ " } else { " " }; + let style = if idx == self.cursor { + styles::bold_primary() + } else { + Style::default() + }; + lines.push(Line::styled(format!(" {prefix}{item}"), style)); + } + } + + // Footer + lines.push(Line::raw("")); + lines.push(Line::raw("")); + lines.push(Line::styled( + " Tab switch category • ↑/↓ navigate • s save • q quit", + styles::faint(), + )); + + frame.render_widget(Paragraph::new(Text::from(lines)), area); + } +} + +pub fn run_whitelist_tui() -> crate::error::Result<()> { + let mut terminal = ratatui::init(); + let mut app = WhitelistTui::new(); + + loop { + terminal + .draw(|f| app.render(f, f.area())) + .map_err(crate::error::Error::Io)?; + + if let crossterm::event::Event::Key(key) = + crossterm::event::read().map_err(crate::error::Error::Io)? + && app.handle_key_event(key) + { + break; + } + } + + ratatui::restore(); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_whitelist_tui_tab_switch() { + let mut tui = WhitelistTui::new(); + assert_eq!(tui.tab, WhitelistTab::ProtectedPaths); + + let tab_key = KeyEvent::from(KeyCode::Tab); + assert!(!tui.handle_key_event(tab_key)); + assert_eq!(tui.tab, WhitelistTab::CacheSkip); + + assert!(!tui.handle_key_event(tab_key)); + assert_eq!(tui.tab, WhitelistTab::OptimizeSkip); + + assert!(!tui.handle_key_event(tab_key)); + assert_eq!(tui.tab, WhitelistTab::ProtectedPaths); + } + + #[test] + fn test_whitelist_tui_quit() { + let mut tui = WhitelistTui::new(); + let q_key = KeyEvent::from(KeyCode::Char('q')); + assert!(tui.handle_key_event(q_key)); + } +} diff --git a/src/update.rs b/src/update.rs new file mode 100644 index 0000000..b7645fa --- /dev/null +++ b/src/update.rs @@ -0,0 +1,387 @@ +//! Self-update subsystem — queries GitHub Releases, verifies SHA-256 checksums, +//! and atomically replaces the running binary. +//! +//! Follows fail-closed security invariant: missing or mismatched checksum aborts +//! before modifying the binary. Supports `--dry-run` and `--force`. + +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::oplog; +use crate::runner::{CommandSpec, ProcessRunner, Runner}; + +const DEFAULT_REPO: &str = "baroitrung/mu"; +const DEFAULT_API_URL: &str = "https://api.github.com/repos/baroitrung/mu/releases/latest"; +const BINARY_NAME: &str = "mu"; + +#[derive(Clone, Debug, Default)] +pub struct Options { + pub dry_run: bool, + pub force: bool, + pub debug: bool, + pub target_bin: Option, + pub api_url: Option, +} + +#[derive(Clone, Debug, serde::Deserialize)] +pub struct ReleaseInfo { + pub tag_name: String, + pub name: Option, + pub body: Option, + #[serde(default)] + pub assets: Vec, +} + +#[derive(Clone, Debug, serde::Deserialize)] +pub struct ReleaseAsset { + pub name: String, + pub browser_download_url: String, + pub size: Option, +} + +/// Compute SHA-256 hex string of bytes. +pub fn compute_sha256(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) +} + +/// Parse GNU sha256sum formatted `checksums.txt` (` `). +pub fn parse_checksum(content: &str, binary_name: &str) -> Option { + for line in content.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + let hash = parts[0]; + let name = parts[1].trim_start_matches('*'); + if name == binary_name { + return Some(hash.to_lowercase()); + } + } + } + None +} + +/// Fetches bytes from a URL using `Runner` (`curl`) or local `file://` scheme. +pub fn fetch_bytes(url: &str, runner: &dyn Runner) -> Result, String> { + if let Some(file_path) = url.strip_prefix("file://") { + return fs::read(file_path) + .map_err(|e| format!("cannot read local file {}: {}", file_path, e)); + } + + let spec = CommandSpec::new("curl", ["-fsSL", url]); + match runner.run(&spec) { + Ok(out) => Ok(out.stdout), + Err(e) => Err(format!("failed to fetch {url}: {e}")), + } +} + +/// Query release info from GitHub API or custom/file URL. +pub fn fetch_release_info(api_url: &str, runner: &dyn Runner) -> Result { + let bytes = fetch_bytes(api_url, runner)?; + serde_json::from_slice::(&bytes) + .map_err(|e| format!("invalid release JSON from {api_url}: {e}")) +} + +pub fn resolve_target_bin(custom: Option<&Path>) -> Result { + if let Some(p) = custom { + return Ok(p.to_path_buf()); + } + std::env::current_exe().map_err(|e| format!("cannot resolve current executable path: {e}")) +} + +pub fn current_version() -> &'static str { + match option_env!("MU_VERSION") { + Some(v) => v, + None => "0.3.0", + } +} + +pub fn is_newer_version(latest_tag: &str, current: &str) -> bool { + let clean_latest = latest_tag.trim_start_matches('v'); + let clean_current = current.trim_start_matches('v'); + if clean_current == "dev" { + return false; + } + clean_latest != clean_current +} + +pub fn run_with(opts: &Options, runner: &dyn Runner) -> i32 { + let env_url = std::env::var("MU_UPDATE_URL").ok(); + let api_url = opts + .api_url + .as_deref() + .or(env_url.as_deref()) + .unwrap_or(DEFAULT_API_URL); + + let target_bin = match resolve_target_bin(opts.target_bin.as_deref()) { + Ok(p) => p, + Err(e) => { + eprintln!("error: {e}"); + return 1; + } + }; + + let curr_ver = current_version(); + let ver_display = if curr_ver.starts_with('v') { + curr_ver.to_string() + } else { + format!("v{curr_ver}") + }; + println!("Checking for updates (current version: {ver_display})..."); + + let release = match fetch_release_info(api_url, runner) { + Ok(rel) => rel, + Err(e) => { + if opts.dry_run { + println!("Notice: could not query GitHub release API ({e})."); + println!("Local version is up-to-date: {ver_display}"); + return 0; + } + eprintln!("error: {e}"); + return 1; + } + }; + + let latest_tag = &release.tag_name; + let has_update = is_newer_version(latest_tag, curr_ver) || opts.force; + + if !has_update { + println!("mu is already up-to-date ({latest_tag})."); + return 0; + } + + println!("Found newer release: {latest_tag}"); + + let bin_asset = release.assets.iter().find(|a| a.name == BINARY_NAME); + let checksum_asset = release.assets.iter().find(|a| a.name == "checksums.txt"); + + if opts.dry_run { + println!("[dry-run] Would update mu to {latest_tag}"); + println!("[dry-run] Target executable: {}", target_bin.display()); + if let Some(b) = bin_asset { + println!("[dry-run] Binary asset: {}", b.browser_download_url); + } else { + println!("[dry-run] Binary asset: default release asset '{BINARY_NAME}'"); + } + if let Some(c) = checksum_asset { + println!("[dry-run] Checksum asset: {}", c.browser_download_url); + } else { + println!("[dry-run] Checksum asset: default release asset 'checksums.txt'"); + } + oplog::log_outcome("update", latest_tag, "dry-run"); + return 0; + } + + // Download checksums.txt + let fallback_checksum_url; + let checksum_url = match checksum_asset { + Some(a) => a.browser_download_url.as_str(), + None => { + fallback_checksum_url = format!( + "https://github.com/{DEFAULT_REPO}/releases/download/{latest_tag}/checksums.txt" + ); + &fallback_checksum_url + } + }; + + println!("Downloading checksums.txt..."); + let checksum_bytes = match fetch_bytes(checksum_url, runner) { + Ok(b) => b, + Err(e) => { + eprintln!("error: fail-closed: cannot download checksums: {e}"); + return 1; + } + }; + + let checksum_text = String::from_utf8_lossy(&checksum_bytes); + let expected_hash = match parse_checksum(&checksum_text, BINARY_NAME) { + Some(h) => h, + None => { + eprintln!("error: fail-closed: checksums.txt missing entry for {BINARY_NAME}"); + return 1; + } + }; + + // Download binary + let fallback_bin_url; + let bin_url = match bin_asset { + Some(a) => a.browser_download_url.as_str(), + None => { + fallback_bin_url = format!( + "https://github.com/{DEFAULT_REPO}/releases/download/{latest_tag}/{BINARY_NAME}" + ); + &fallback_bin_url + } + }; + + println!("Downloading {BINARY_NAME} {latest_tag}..."); + let new_bin_bytes = match fetch_bytes(bin_url, runner) { + Ok(b) => b, + Err(e) => { + eprintln!("error: failed to download binary: {e}"); + return 1; + } + }; + + // Verify SHA-256 + let actual_hash = compute_sha256(&new_bin_bytes); + if actual_hash != expected_hash { + eprintln!("error: SHA-256 checksum mismatch for {BINARY_NAME}!"); + eprintln!(" expected: {expected_hash}"); + eprintln!(" actual: {actual_hash}"); + eprintln!("Update aborted (fail-closed). Current binary was NOT modified."); + oplog::log_outcome("update", latest_tag, "failure-checksum-mismatch"); + return 1; + } + + println!("SHA-256 checksum verified: {actual_hash}"); + + // Atomic replacement + let target_dir = target_bin.parent().unwrap_or_else(|| Path::new(".")); + let temp_target = target_dir.join(format!(".mu-update-{}", std::process::id())); + + if let Err(e) = fs::write(&temp_target, &new_bin_bytes) { + eprintln!( + "error: cannot write to {} (permission denied? run with sudo): {}", + temp_target.display(), + e + ); + return 1; + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(&temp_target, fs::Permissions::from_mode(0o755)); + } + + if let Err(e) = fs::rename(&temp_target, &target_bin) { + let _ = fs::remove_file(&temp_target); + eprintln!("error: failed to replace {}: {}", target_bin.display(), e); + return 1; + } + + println!("Successfully updated mu to {latest_tag}!"); + oplog::log_outcome("update", latest_tag, "success"); + 0 +} + +pub fn run(opts: &Options) -> i32 { + run_with(opts, &ProcessRunner) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_compute_sha256() { + let data = b"hello world\n"; + let hash = compute_sha256(data); + assert_eq!( + hash, + "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447" + ); + } + + #[test] + fn test_parse_checksum() { + let content = "\ +a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447 mu +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 *other +"; + let hash = parse_checksum(content, "mu"); + assert_eq!( + hash.unwrap(), + "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447" + ); + + let other = parse_checksum(content, "other"); + assert_eq!( + other.unwrap(), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + + assert!(parse_checksum(content, "nonexistent").is_none()); + } + + #[test] + fn test_offline_update_verification() { + let tmp = std::env::temp_dir().join(format!("mu-update-test-{}", std::process::id())); + let _ = fs::create_dir_all(&tmp); + + let target_bin = tmp.join("mu"); + fs::write(&target_bin, b"original-mu").unwrap(); + + let new_bin_content = b"brand-new-mu-v0.4.0"; + let new_bin_hash = compute_sha256(new_bin_content); + + let new_bin_file = tmp.join("new-mu"); + fs::write(&new_bin_file, new_bin_content).unwrap(); + + let checksum_file = tmp.join("checksums.txt"); + fs::write(&checksum_file, format!("{new_bin_hash} mu\n")).unwrap(); + + let release_json = tmp.join("release.json"); + let json_content = serde_json::json!({ + "tag_name": "v0.4.0", + "name": "v0.4.0", + "assets": [ + { + "name": "mu", + "browser_download_url": format!("file://{}", new_bin_file.display()) + }, + { + "name": "checksums.txt", + "browser_download_url": format!("file://{}", checksum_file.display()) + } + ] + }); + fs::write(&release_json, json_content.to_string()).unwrap(); + + // Dry-run + let dry_opts = Options { + dry_run: true, + force: true, + target_bin: Some(target_bin.clone()), + api_url: Some(format!("file://{}", release_json.display())), + ..Default::default() + }; + let code = run_with(&dry_opts, &ProcessRunner); + assert_eq!(code, 0); + assert_eq!(fs::read(&target_bin).unwrap(), b"original-mu"); + + // Real update + let real_opts = Options { + dry_run: false, + force: true, + target_bin: Some(target_bin.clone()), + api_url: Some(format!("file://{}", release_json.display())), + ..Default::default() + }; + let code = run_with(&real_opts, &ProcessRunner); + assert_eq!(code, 0); + assert_eq!(fs::read(&target_bin).unwrap(), new_bin_content); + + // Mismatched checksum fails closed + fs::write( + &checksum_file, + "0000000000000000000000000000000000000000000000000000000000000000 mu\n", + ) + .unwrap(); + let fail_opts = Options { + dry_run: false, + force: true, + target_bin: Some(target_bin.clone()), + api_url: Some(format!("file://{}", release_json.display())), + ..Default::default() + }; + let code = run_with(&fail_opts, &ProcessRunner); + assert_eq!(code, 1); + // Target binary remains unchanged + assert_eq!(fs::read(&target_bin).unwrap(), new_bin_content); + + let _ = fs::remove_dir_all(&tmp); + } +} diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 002d431..3037762 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -169,6 +169,9 @@ fn help_lists_subcommands() { "status", "uninstall", "completion", + "remove", + "sudo", + "update", ] { assert!(out.contains(cmd), "missing {cmd} in help"); } @@ -305,3 +308,98 @@ fn analyze_relative_path_dry_run_reports_absolute_target() { let _ = std::fs::remove_dir_all(&test_dir); } + +/// `mu sudo status` -> reports PAM fingerprint status, exit 0. +#[test] +fn sudo_status_output() { + let o = mu(&["sudo", "status"]); + assert_eq!(o.status.code(), Some(0)); + let out = stdout(&o); + assert!( + out.contains("Fingerprint authentication for sudo:"), + "unexpected output: {out}" + ); +} + +/// `mu sudo --dry-run enable` -> previews changes without modifying files, exit 0. +#[test] +fn sudo_dry_run_enable() { + let o = mu(&["sudo", "--dry-run", "enable"]); + assert_eq!(o.status.code(), Some(0)); + let out = stdout(&o); + assert!( + out.contains("[dry-run] Would enable fingerprint authentication for sudo"), + "unexpected output: {out}" + ); +} + +/// `mu update --dry-run` -> reports local version and status, exit 0. +#[test] +fn update_dry_run() { + let o = mu(&["update", "--dry-run"]); + assert_eq!(o.status.code(), Some(0)); + let out = stdout(&o); + assert!( + out.contains("Checking for updates") || out.contains("up-to-date"), + "unexpected output: {out}" + ); +} + +/// `mu remove --dry-run` sentinel immutability test -> exit 0, sentinel untouched. +#[test] +fn remove_dry_run_sentinel() { + let test_dir = std::env::temp_dir().join(format!("mu-remove-sentinel-{}", std::process::id())); + let config_dir = test_dir.join(".config/mu"); + let _ = std::fs::create_dir_all(&config_dir); + let sentinel = config_dir.join("config.toml"); + std::fs::write(&sentinel, b"# sentinel config").unwrap(); + + let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_mu")); + cmd.args(["remove", "--dry-run"]); + cmd.env("HOME", &test_dir); + cmd.env("XDG_CONFIG_HOME", test_dir.join(".config")); + cmd.env("XDG_CACHE_HOME", test_dir.join(".cache")); + cmd.env("XDG_DATA_HOME", test_dir.join(".local/share")); + let output = cmd.output().expect("failed to run mu remove --dry-run"); + + assert_eq!(output.status.code(), Some(0)); + let out = String::from_utf8_lossy(&output.stdout); + assert!(out.contains("[dry-run]"), "expected dry run notice: {out}"); + assert!( + sentinel.exists(), + "sentinel config.toml must not be deleted during --dry-run" + ); + + let _ = std::fs::remove_dir_all(&test_dir); +} + +/// `mu completion --install bash` -> writes completion script, exit 0. +#[test] +fn completion_install_bash() { + let test_dir = std::env::temp_dir().join(format!("mu-comp-install-{}", std::process::id())); + let _ = std::fs::create_dir_all(&test_dir); + + let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_mu")); + cmd.args(["completion", "--install", "bash"]); + cmd.env("HOME", &test_dir); + cmd.env("XDG_DATA_HOME", test_dir.join(".local/share")); + let output = cmd + .output() + .expect("failed to run mu completion --install bash"); + + assert_eq!(output.status.code(), Some(0)); + let out = String::from_utf8_lossy(&output.stdout); + assert!( + out.contains("Installed completion for bash"), + "expected install confirmation in output: {out}" + ); + + let expected_file = test_dir.join(".local/share/bash-completion/completions/mu"); + assert!( + expected_file.exists(), + "installed completion file must exist at {}", + expected_file.display() + ); + + let _ = std::fs::remove_dir_all(&test_dir); +} diff --git a/tests/golden/help-remove.exitcode b/tests/golden/help-remove.exitcode new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/golden/help-remove.exitcode @@ -0,0 +1 @@ +0 diff --git a/tests/golden/help-remove.stderr.txt b/tests/golden/help-remove.stderr.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/golden/help-remove.txt b/tests/golden/help-remove.txt new file mode 100644 index 0000000..3b81868 --- /dev/null +++ b/tests/golden/help-remove.txt @@ -0,0 +1,13 @@ +Safely uninstall mu and remove configuration, logs, and cache. +All deletions route strictly through trash::SafeDelete. + +Usage: + mu remove [flags] + +Flags: + --dry-run Preview actions without making changes + -h, --help help for remove + -y, --yes Confirm removal without prompting + +Global Flags: + --debug Enable verbose debug logging diff --git a/tests/golden/help-sudo.exitcode b/tests/golden/help-sudo.exitcode new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/golden/help-sudo.exitcode @@ -0,0 +1 @@ +0 diff --git a/tests/golden/help-sudo.stderr.txt b/tests/golden/help-sudo.stderr.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/golden/help-sudo.txt b/tests/golden/help-sudo.txt new file mode 100644 index 0000000..b22da67 --- /dev/null +++ b/tests/golden/help-sudo.txt @@ -0,0 +1,20 @@ +Configure fingerprint authentication for sudo (pam_fprintd). +Enables or disables biometric authentication in /etc/pam.d/sudo with +automatic backup creation to prevent user lockout. + +Usage: + mu sudo [command] [flags] + +Available Commands: + disable Disable fingerprint authentication for sudo + enable Enable fingerprint authentication for sudo + status Show current fingerprint authentication status + +Flags: + --dry-run Preview actions without making changes + -h, --help help for sudo + +Global Flags: + --debug Enable verbose debug logging + +Use "mu sudo [command] --help" for more information about a command. diff --git a/tests/golden/help-update.exitcode b/tests/golden/help-update.exitcode new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/golden/help-update.exitcode @@ -0,0 +1 @@ +0 diff --git a/tests/golden/help-update.stderr.txt b/tests/golden/help-update.stderr.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/golden/help-update.txt b/tests/golden/help-update.txt new file mode 100644 index 0000000..59d958b --- /dev/null +++ b/tests/golden/help-update.txt @@ -0,0 +1,13 @@ +Check for updates and self-update to the latest release. +Verifies SHA-256 cryptographic checksums before replacing the binary. + +Usage: + mu update [flags] + +Flags: + --dry-run Check for updates without downloading or installing + --force Force reinstall or re-download even if already up-to-date + -h, --help help for update + +Global Flags: + --debug Enable verbose debug logging diff --git a/tests/golden/help.txt b/tests/golden/help.txt index 02d1913..c99935d 100644 --- a/tests/golden/help.txt +++ b/tests/golden/help.txt @@ -14,8 +14,11 @@ Available Commands: completion Generate the autocompletion script for the specified shell help Help about any command optimize Run system maintenance: apt autoremove, journal vacuum, cache refresh + remove Safely uninstall mu and remove configuration, logs, and cache status Live system dashboard: CPU, RAM, disk, network, health score + sudo Configure fingerprint authentication for sudo (pam_fprintd) uninstall Remove apps and all their config, cache, and data remnants + update Check for updates and self-update to the latest release Flags: --debug Enable verbose debug logging From 69f30f58c2f4164d55df3a88083de1afa5b168e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ba=20R=E1=BB=8Di?= Date: Wed, 14 Oct 2026 17:08:10 +0700 Subject: [PATCH 03/17] feat(optimize): implement Phase 3 optimize expansion and opt-in clean targets --- src/audit/mod.rs | 3 + src/clean/mod.rs | 4 + src/clean/targets.rs | 321 +++++++++++++++++++ src/optimize.rs | 668 +++++++++++++++++++++++++++++++++++++-- tests/cli_integration.rs | 99 ++++++ 5 files changed, 1068 insertions(+), 27 deletions(-) diff --git a/src/audit/mod.rs b/src/audit/mod.rs index 5917994..4f57788 100644 --- a/src/audit/mod.rs +++ b/src/audit/mod.rs @@ -312,6 +312,9 @@ pub(crate) mod test_support { confirm: Arc::new(|_| panic!("confirm must not be reached")), config_home: root.to_path_buf(), data_home: root.join("data"), + home: root.to_path_buf(), + state_home: root.join("state"), + trash_deps: std::rc::Rc::new(crate::trash::TrashDeps::real()), no_oplog: true, }, } diff --git a/src/clean/mod.rs b/src/clean/mod.rs index b4ab35c..a01bdef 100644 --- a/src/clean/mod.rs +++ b/src/clean/mod.rs @@ -138,6 +138,10 @@ pub(crate) fn all_targets_in(deps: &Deps) -> Vec { scan_snap::snap_target_in(deps), scan_kernels::kernels_target_in(deps), scan_browser::browser_cache_target_in(deps), + targets::dev_cache_target_in(deps), + targets::podman_target_in(deps), + targets::orphaned_app_data_target_in(deps), + targets::orphaned_services_target_in(deps), ]; if let Some(docker) = scan_docker::docker_target_in(deps) { targets.push(docker); diff --git a/src/clean/targets.rs b/src/clean/targets.rs index 60f67a1..0616026 100644 --- a/src/clean/targets.rs +++ b/src/clean/targets.rs @@ -564,6 +564,327 @@ fn parse_journal_disk_usage(out: &str) -> std::result::Result<(f64, String), Str } } +pub(crate) fn dev_cache_target_in(deps: &Deps) -> CleanTarget { + let scan_home = deps.home.clone(); + let exec_home = deps.home.clone(); + let exec_cfg = deps.config_home.clone(); + let exec_runner = Arc::clone(&deps.trash_runner); + let exec_deps = deps.trash_deps.clone(); + + CleanTarget { + id: "dev-cache", + label: "Developer CLI Caches", + requires_sudo: false, + opt_in: true, + scan: Box::new(move || { + let mut total: i64 = 0; + let mut scan_errors: Vec = Vec::new(); + for p in dev_cache_paths(&scan_home) { + if paths::path_exists(&p) { + let (sz, err) = crate::size::dir_size(&p); + match err { + None => total += sz as i64, + Some(e) => scan_errors.push(e), + } + } + } + if scan_errors.is_empty() { + (total, None) + } else { + (total, Some(super::join_errors(&scan_errors))) + } + }), + preview: None, + execute: Box::new(move |dry_run| { + let mut delete_errors: Vec = Vec::new(); + for p in dev_cache_paths(&exec_home) { + if !paths::path_exists(&p) { + continue; + } + let root = dev_cache_root(&exec_home, &p); + if let Err(e) = paths::validate_cleanup_candidate(&root, &p) { + delete_errors.push(e); + continue; + } + if let Err(e) = + trash::safe_delete_with(&*exec_runner, &exec_cfg, &exec_deps, &p, dry_run) + { + delete_errors.push(e); + } + } + if delete_errors.is_empty() { + Ok(()) + } else { + Err(super::join_errors(&delete_errors)) + } + }), + } +} + +fn dev_cache_paths(home: &Path) -> Vec { + vec![ + home.join(".cache/pip"), + home.join(".npm/_cacache"), + home.join(".cargo/registry/cache"), + home.join(".cache/go-build"), + ] +} + +fn dev_cache_root(home: &Path, p: &Path) -> PathBuf { + let cache = home.join(".cache"); + if p.starts_with(&cache) { + return cache; + } + let npm = home.join(".npm"); + if p.starts_with(&npm) { + return npm; + } + let cargo = home.join(".cargo"); + if p.starts_with(&cargo) { + return cargo; + } + p.parent().unwrap_or(home).to_path_buf() +} + +pub(crate) fn podman_target_in(deps: &Deps) -> CleanTarget { + let runner = Arc::clone(&deps.runner); + let exec_runner = Arc::clone(&deps.runner); + + CleanTarget { + id: "podman", + label: "Podman System Cache", + requires_sudo: false, + opt_in: true, + scan: Box::new(move || { + if runner.look_path("podman").is_err() { + return (0, None); + } + (0, None) + }), + preview: None, + execute: Box::new(move |dry_run| { + if exec_runner.look_path("podman").is_err() { + return Ok(()); + } + if dry_run { + oplog::log_outcome("podman", "system prune -f", "dry-run"); + return Ok(()); + } + let res = exec_runner.run(&CommandSpec::new("podman", ["system", "prune", "-f"])); + res.map(|_| ()).map_err(|e| Error::Msg(format!("podman system prune: {e}"))) + }), + } +} + +pub(crate) fn orphaned_app_data_target_in(deps: &Deps) -> CleanTarget { + let scan_home = deps.home.clone(); + let scan_cfg = deps.config_home.clone(); + let scan_data = deps.data_home.clone(); + let scan_runner = Arc::clone(&deps.runner); + + let exec_home = deps.home.clone(); + let exec_cfg = deps.config_home.clone(); + let exec_data = deps.data_home.clone(); + let exec_runner = Arc::clone(&deps.trash_runner); + let exec_deps = deps.trash_deps.clone(); + + CleanTarget { + id: "orphaned-app-data", + label: "Orphaned Application Data", + requires_sudo: false, + opt_in: true, + scan: Box::new(move || { + let candidates = find_orphaned_apps(&scan_home, &scan_cfg, &scan_data, scan_runner.as_ref()); + let mut total: i64 = 0; + for p in &candidates { + let (sz, _) = crate::size::dir_size(p); + total += sz as i64; + } + (total, None) + }), + preview: None, + execute: Box::new(move |dry_run| { + let candidates = find_orphaned_apps(&exec_home, &exec_cfg, &exec_data, exec_runner.as_ref()); + let mut delete_errors: Vec = Vec::new(); + for p in candidates { + let root = if p.starts_with(&exec_cfg) { + &exec_cfg + } else { + &exec_data + }; + if let Err(e) = paths::validate_cleanup_candidate(root, &p) { + delete_errors.push(e); + continue; + } + if let Err(e) = + trash::safe_delete_with(&*exec_runner, &exec_cfg, &exec_deps, &p, dry_run) + { + delete_errors.push(e); + } + } + if delete_errors.is_empty() { + Ok(()) + } else { + Err(super::join_errors(&delete_errors)) + } + }), + } +} + +fn find_orphaned_apps( + home: &Path, + config_home: &Path, + data_home: &Path, + runner: &dyn Runner, +) -> Vec { + if home.as_os_str().is_empty() { + return Vec::new(); + } + const PROTECTED_CONFIG: &[&str] = &[ + "dconf", "pulse", "systemd", "autostart", "gtk-3.0", "gtk-4.0", "glib-2.0", + "fontconfig", "ibus", "gnome-session", "environment.d", "mu", "mimeapps.list", + "user-dirs.dirs", "user-dirs.locale", "gnome-control-center", "BraveSoftware", + "google-chrome", "chromium", "microsoft-edge", "mozilla", + ]; + const PROTECTED_DATA: &[&str] = &[ + "Trash", "applications", "icons", "themes", "fonts", "mime", "keyrings", + "systemd", "mu", "flatpak", "gvfs-metadata", "sounds", "desktop-directories", + ]; + + let mut installed = std::collections::HashSet::new(); + if let Ok(apt_pkgs) = crate::uninstall::discover::discover_apt(runner) { + for p in apt_pkgs { + installed.insert(p.name.to_lowercase()); + if let Some(alias) = crate::uninstall::remnants::known_alias(&p.name) { + installed.insert(alias.to_lowercase()); + } + } + } + if let Ok(snap_pkgs) = crate::uninstall::discover::discover_snap(runner) { + for p in snap_pkgs { + installed.insert(p.name.to_lowercase()); + if let Some(alias) = crate::uninstall::remnants::known_alias(&p.name) { + installed.insert(alias.to_lowercase()); + } + } + } + if runner.look_path("flatpak").is_ok() { + if let Ok(out) = runner.run(&CommandSpec::new( + "flatpak", + ["list", "--app", "--columns=application"], + )) { + for line in out.stdout_lossy().lines() { + let id = line.trim(); + installed.insert(id.to_lowercase()); + if let Some(last) = id.split('.').last() { + installed.insert(last.to_lowercase()); + } + } + } + } + + let mut candidates = Vec::new(); + if let Ok(entries) = std::fs::read_dir(config_home) { + for entry in entries.flatten() { + let p = entry.path(); + if !p.is_dir() { + continue; + } + let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default(); + if PROTECTED_CONFIG.iter().any(|&prot| prot.eq_ignore_ascii_case(name)) { + continue; + } + let lower = name.to_lowercase(); + if installed.contains(&lower) { + continue; + } + if runner.look_path(name).is_ok() || runner.look_path(&lower).is_ok() { + continue; + } + candidates.push(p); + } + } + if let Ok(entries) = std::fs::read_dir(data_home) { + for entry in entries.flatten() { + let p = entry.path(); + if !p.is_dir() { + continue; + } + let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default(); + if PROTECTED_DATA.iter().any(|&prot| prot.eq_ignore_ascii_case(name)) { + continue; + } + let lower = name.to_lowercase(); + if installed.contains(&lower) { + continue; + } + if runner.look_path(name).is_ok() || runner.look_path(&lower).is_ok() { + continue; + } + candidates.push(p); + } + } + candidates.sort(); + candidates +} + +pub(crate) fn orphaned_services_target_in(deps: &Deps) -> CleanTarget { + let scan_home = deps.home.clone(); + let exec_home = deps.home.clone(); + let exec_cfg = deps.config_home.clone(); + let exec_runner = Arc::clone(&deps.trash_runner); + let exec_deps = deps.trash_deps.clone(); + + CleanTarget { + id: "orphaned-services", + label: "Orphaned System Services", + requires_sudo: false, + opt_in: true, + scan: Box::new(move || { + let broken = find_broken_service_symlinks(&scan_home); + let total = (broken.len() as i64) * 1024; + (total, None) + }), + preview: None, + execute: Box::new(move |dry_run| { + let broken = find_broken_service_symlinks(&exec_home); + let mut delete_errors: Vec = Vec::new(); + let user_dir = exec_home.join(".config/systemd/user"); + for p in broken { + if paths::path_contains(&user_dir, &p) { + if let Err(e) = + trash::safe_delete_with(&*exec_runner, &exec_cfg, &exec_deps, &p, dry_run) + { + delete_errors.push(e); + } + } + } + if delete_errors.is_empty() { + Ok(()) + } else { + Err(super::join_errors(&delete_errors)) + } + }), + } +} + +fn find_broken_service_symlinks(home: &Path) -> Vec { + let mut broken = Vec::new(); + let user_dir = home.join(".config/systemd/user"); + if let Ok(entries) = std::fs::read_dir(user_dir) { + for entry in entries.flatten() { + let p = entry.path(); + if let Ok(m) = p.symlink_metadata() { + if m.file_type().is_symlink() && std::fs::metadata(&p).is_err() { + broken.push(p); + } + } + } + } + broken.sort(); + broken +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/optimize.rs b/src/optimize.rs index b58860a..83a3940 100644 --- a/src/optimize.rs +++ b/src/optimize.rs @@ -20,14 +20,15 @@ use std::io::{IsTerminal, Write}; use std::path::{Path, PathBuf}; +use std::rc::Rc; use std::sync::Arc; use crate::config; use crate::error::{Error, Result, msg}; use crate::oplog; use crate::runner::{CommandSpec, Output, ProcessRunner, RunError, Runner}; +use crate::trash; use crate::xdg; - /// `Options` — controls optimize behavior. #[derive(Debug, Default, Clone)] pub struct Options { @@ -54,6 +55,12 @@ pub(crate) struct Deps { pub config_home: PathBuf, /// `$XDG_DATA_HOME` — where `mu/operations.log` is opened. pub data_home: PathBuf, + /// `$HOME` — user home directory. + pub home: PathBuf, + /// `$XDG_STATE_HOME` or `~/.local/state`. + pub state_home: PathBuf, + /// `TrashDeps` for safe deletion. + pub trash_deps: Rc, /// `MU_NO_OPLOG=1` — disables `InitLogger`. pub no_oplog: bool, } @@ -72,6 +79,11 @@ impl Deps { confirm: Arc::new(confirm_prompt), config_home: xdg::config_home(), data_home: xdg::data_home(), + home: xdg::home_dir(), + state_home: std::env::var_os("XDG_STATE_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| xdg::home_dir().join(".local/state")), + trash_deps: Rc::new(trash::TrashDeps::real()), no_oplog: std::env::var_os("MU_NO_OPLOG").is_some_and(|v| v == "1"), } } @@ -81,19 +93,39 @@ impl Deps { /// stdout/stderr to the sink. type StepRun = Box Result<()>>; -/// `step` — one optimize step: stable ID, plan description, action. pub(crate) struct Step { pub id: &'static str, pub desc: &'static str, + pub dry_run_cmd: &'static str, + pub dry_run_lines: Option Vec + Send + Sync>>, pub run: StepRun, } +impl Step { + pub(crate) fn new( + id: &'static str, + desc: &'static str, + dry_run_cmd: &'static str, + run: StepRun, + ) -> Self { + Self { + id, + desc, + dry_run_cmd, + dry_run_lines: None, + run, + } + } +} + /// `allSteps` — apt, journal, caches, in that order. fn all_steps(deps: &Deps) -> Vec { vec![ Step { id: "apt", desc: "apt-get update && apt-get autoremove --purge", + dry_run_cmd: "sudo apt-get update && sudo apt-get autoremove --purge -y", + dry_run_lines: None, run: { let runner = deps.runner.clone(); let autoremove = deps.autoremove.clone(); @@ -105,6 +137,8 @@ fn all_steps(deps: &Deps) -> Vec { Step { id: "journal", desc: "journalctl --vacuum-size=500M", + dry_run_cmd: "sudo journalctl --vacuum-size=500M", + dry_run_lines: None, run: { let runner = deps.runner.clone(); Box::new(move |out: &mut dyn Write| journal_vacuum(runner.as_ref(), out)) @@ -113,11 +147,134 @@ fn all_steps(deps: &Deps) -> Vec { Step { id: "caches", desc: "update icon/mime/font caches", + dry_run_cmd: "sudo update-mime-database /usr/share/mime && fc-cache -f", + dry_run_lines: None, run: { let runner = deps.runner.clone(); Box::new(move |out: &mut dyn Write| update_caches(runner.as_ref(), out)) }, }, + Step { + id: "dns", + desc: "flush DNS cache", + dry_run_cmd: "resolvectl flush-caches", + dry_run_lines: None, + run: { + let runner = deps.runner.clone(); + Box::new(move |out: &mut dyn Write| dns_flush(runner.as_ref(), out)) + }, + }, + Step { + id: "packages", + desc: "repair interrupted package installs", + dry_run_cmd: "sudo dpkg --configure -a && sudo apt-get install -f -y", + dry_run_lines: None, + run: { + let runner = deps.runner.clone(); + Box::new(move |out: &mut dyn Write| packages_repair(runner.as_ref(), out)) + }, + }, + Step { + id: "systemd", + desc: "reset failed systemd units", + dry_run_cmd: "systemctl reset-failed && systemctl --user reset-failed", + dry_run_lines: None, + run: { + let runner = deps.runner.clone(); + Box::new(move |out: &mut dyn Write| systemd_reset_failed(runner.as_ref(), out)) + }, + }, + Step { + id: "flatpak", + desc: "clean unused Flatpak runtimes", + dry_run_cmd: "flatpak uninstall --unused -y", + dry_run_lines: None, + run: { + let runner = deps.runner.clone(); + Box::new(move |out: &mut dyn Write| flatpak_clean_unused(runner.as_ref(), out)) + }, + }, + Step { + id: "sqlite", + desc: "vacuum application sqlite databases", + dry_run_cmd: "sqlite3 \"VACUUM;\"", + dry_run_lines: Some(Arc::new(|d: &Deps| { + let candidates = find_sqlite_candidates(&d.home); + candidates + .into_iter() + .map(|p| format!("sqlite3 {} \"VACUUM;\"", p.display())) + .collect() + })), + run: { + let runner = deps.runner.clone(); + let home = deps.home.clone(); + Box::new(move |out: &mut dyn Write| sqlite_vacuum(runner.as_ref(), &home, out)) + }, + }, + Step { + id: "state", + desc: "clean stale files older than 30 days in ~/.local/state", + dry_run_cmd: "find ~/.local/state -type f -mtime +30 -delete", + dry_run_lines: None, + run: { + let runner = deps.runner.clone(); + let config_home = deps.config_home.clone(); + let state_home = deps.state_home.clone(); + let trash_deps = deps.trash_deps.clone(); + Box::new(move |out: &mut dyn Write| { + state_clean( + runner.as_ref(), + &config_home, + &state_home, + trash_deps.as_ref(), + out, + ) + }) + }, + }, + Step { + id: "autostart", + desc: "audit autostart desktop entries for missing binaries", + dry_run_cmd: "audit ~/.config/autostart/*.desktop for missing Exec binaries", + dry_run_lines: None, + run: { + let runner = deps.runner.clone(); + let config_home = deps.config_home.clone(); + Box::new(move |out: &mut dyn Write| { + autostart_audit(runner.as_ref(), &config_home, out) + }) + }, + }, + Step { + id: "services", + desc: "audit broken systemd service symlinks", + dry_run_cmd: "audit broken symlinks in /etc/systemd/system and ~/.config/systemd/user", + dry_run_lines: None, + run: { + let config_home = deps.config_home.clone(); + Box::new(move |out: &mut dyn Write| services_audit(&config_home, out)) + }, + }, + Step { + id: "permissions", + desc: "audit root-owned files in home directory", + dry_run_cmd: "audit files in $HOME owned by root", + dry_run_lines: None, + run: { + let home = deps.home.clone(); + Box::new(move |out: &mut dyn Write| permissions_audit(&home, out)) + }, + }, + Step { + id: "disk-health", + desc: "audit filesystem integrity and SMART disk health", + dry_run_cmd: "smartctl --scan || journalctl -k --grep=EXT4-fs", + dry_run_lines: None, + run: { + let runner = deps.runner.clone(); + Box::new(move |out: &mut dyn Write| disk_health_audit(runner.as_ref(), out)) + }, + }, ] } @@ -225,7 +382,20 @@ pub(crate) fn run_with(opts: &Options, deps: &Deps, out: &mut dyn Write) -> Resu let _ = writeln!(out, "\n Optimize plan"); for s in &steps { if skip.iter().any(|k| k == s.id) { - let _ = writeln!(out, " [skip] {}", s.desc); + let _ = writeln!(out, " [skip] {}", s.id); + } else if opts.dry_run { + if let Some(lines_fn) = &s.dry_run_lines { + let lines = lines_fn(deps); + if lines.is_empty() { + let _ = writeln!(out, " [dry-run] {}", s.dry_run_cmd); + } else { + for line in lines { + let _ = writeln!(out, " [dry-run] {}", line); + } + } + } else { + let _ = writeln!(out, " [dry-run] {}", s.dry_run_cmd); + } } else { let _ = writeln!(out, " [run] {}", s.desc); } @@ -453,6 +623,439 @@ fn update_caches(runner: &dyn Runner, out: &mut dyn Write) -> Result<()> { } } +fn dns_flush(runner: &dyn Runner, out: &mut dyn Write) -> Result<()> { + if runner.look_path("resolvectl").is_ok() { + let res = runner.run(&CommandSpec::new("resolvectl", ["flush-caches"])); + write_captured(out, &res); + if let Err(e) = res { + if runner.look_path("systemd-resolve").is_ok() { + let res2 = runner.run(&CommandSpec::new("systemd-resolve", ["--flush-caches"])); + write_captured(out, &res2); + res2.map(|_| ()).map_err(|e2| Error::Msg(format!("systemd-resolve --flush-caches: {e2}")))?; + } else { + return Err(Error::Msg(format!("resolvectl flush-caches: {e}"))); + } + } + } else if runner.look_path("systemd-resolve").is_ok() { + let res = runner.run(&CommandSpec::new("systemd-resolve", ["--flush-caches"])); + write_captured(out, &res); + res.map(|_| ()).map_err(|e| Error::Msg(format!("systemd-resolve --flush-caches: {e}")))?; + } else { + let _ = writeln!(out, "neither resolvectl nor systemd-resolve found; skipped"); + } + Ok(()) +} + +fn packages_repair(runner: &dyn Runner, out: &mut dyn Write) -> Result<()> { + let res1 = runner.run(&CommandSpec::new("sudo", ["dpkg", "--configure", "-a"])); + write_captured(out, &res1); + let err1 = res1.err(); + + let res2 = runner.run(&CommandSpec::new("sudo", ["apt-get", "install", "-f", "-y"])); + write_captured(out, &res2); + let err2 = res2.err(); + + match (err1, err2) { + (Some(e1), Some(e2)) => Err(Error::Msg(format!("{e1}\n{e2}"))), + (Some(e1), None) => Err(Error::Msg(e1.to_string())), + (None, Some(e2)) => Err(Error::Msg(e2.to_string())), + (None, None) => Ok(()), + } +} + +fn systemd_reset_failed(runner: &dyn Runner, out: &mut dyn Write) -> Result<()> { + let mut errs = Vec::new(); + let res1 = runner.run(&CommandSpec::new("systemctl", ["reset-failed"])); + write_captured(out, &res1); + if let Err(e) = res1 { + errs.push(e.to_string()); + } + let res2 = runner.run(&CommandSpec::new("systemctl", ["--user", "reset-failed"])); + write_captured(out, &res2); + if let Err(e) = res2 { + errs.push(e.to_string()); + } + if errs.is_empty() { + Ok(()) + } else { + Err(Error::Msg(errs.join("\n"))) + } +} + +fn flatpak_clean_unused(runner: &dyn Runner, out: &mut dyn Write) -> Result<()> { + if runner.look_path("flatpak").is_err() { + let _ = writeln!(out, "flatpak not found; skipped"); + return Ok(()); + } + let res = runner.run(&CommandSpec::new("flatpak", ["uninstall", "--unused", "-y"])); + write_captured(out, &res); + res.map(|_| ()).map_err(|e| Error::Msg(format!("flatpak uninstall --unused: {e}")))?; + Ok(()) +} + +fn find_sqlite_candidates(home: &Path) -> Vec { + let mut candidates = Vec::new(); + // Firefox + let ff = home.join(".mozilla/firefox"); + if let Ok(entries) = std::fs::read_dir(&ff) { + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() { + if let Ok(sub) = std::fs::read_dir(&p) { + for f in sub.flatten() { + let fp = f.path(); + if fp.extension().and_then(|s| s.to_str()) == Some("sqlite") { + candidates.push(fp); + } + } + } + } + } + } + // Google Chrome + let chrome_default = home.join(".config/google-chrome/Default"); + if chrome_default.is_dir() { + for name in &["History", "Favicons", "Cookies", "Web Data"] { + let p = chrome_default.join(name); + if p.is_file() { + candidates.push(p); + } + } + if let Ok(sub) = std::fs::read_dir(&chrome_default) { + for f in sub.flatten() { + let fp = f.path(); + if fp.extension().and_then(|s| s.to_str()) == Some("sqlite") { + candidates.push(fp); + } + } + } + } + // Chromium + let chromium_default = home.join(".config/chromium/Default"); + if chromium_default.is_dir() { + for name in &["History", "Favicons", "Cookies", "Web Data"] { + let p = chromium_default.join(name); + if p.is_file() { + candidates.push(p); + } + } + if let Ok(sub) = std::fs::read_dir(&chromium_default) { + for f in sub.flatten() { + let fp = f.path(); + if fp.extension().and_then(|s| s.to_str()) == Some("sqlite") { + candidates.push(fp); + } + } + } + } + // Thunderbird + let tb = home.join(".thunderbird"); + if let Ok(entries) = std::fs::read_dir(&tb) { + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() { + if let Ok(sub) = std::fs::read_dir(&p) { + for f in sub.flatten() { + let fp = f.path(); + if fp.extension().and_then(|s| s.to_str()) == Some("sqlite") { + candidates.push(fp); + } + } + } + } + } + } + candidates.sort(); + candidates.dedup(); + candidates +} + +fn is_process_running(name: &str) -> bool { + if let Ok(entries) = std::fs::read_dir("/proc") { + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + if let Ok(comm) = std::fs::read_to_string(path.join("comm")) { + if comm.trim() == name { + return true; + } + } + } + } + false +} + +fn sqlite_vacuum(runner: &dyn Runner, home: &Path, out: &mut dyn Write) -> Result<()> { + if runner.look_path("sqlite3").is_err() { + let _ = writeln!(out, "sqlite3 not found in PATH; skipped"); + return Ok(()); + } + let candidates = find_sqlite_candidates(home); + if candidates.is_empty() { + let _ = writeln!(out, "no application sqlite databases found"); + return Ok(()); + } + let firefox_running = is_process_running("firefox"); + let chrome_running = is_process_running("chrome") + || is_process_running("google-chrome") + || is_process_running("chromium"); + let thunderbird_running = is_process_running("thunderbird"); + + let mut vacuumed = 0; + for db in candidates { + let db_str = db.to_string_lossy(); + if firefox_running && db_str.contains(".mozilla") { + let _ = writeln!(out, "sqlite: skipping {} (firefox is running)", db.display()); + continue; + } + if chrome_running && (db_str.contains("google-chrome") || db_str.contains("chromium")) { + let _ = writeln!(out, "sqlite: skipping {} (chrome is running)", db.display()); + continue; + } + if thunderbird_running && db_str.contains(".thunderbird") { + let _ = writeln!(out, "sqlite: skipping {} (thunderbird is running)", db.display()); + continue; + } + let wal = db.with_extension("sqlite-wal"); + let journal = db.with_extension("sqlite-journal"); + if wal.exists() || journal.exists() { + let _ = writeln!(out, "sqlite: skipping {} (active lock/journal)", db.display()); + continue; + } + let res = runner.run(&CommandSpec::new( + "sqlite3", + [db_str.as_ref(), "VACUUM;"], + )); + if let Err(e) = res { + let _ = writeln!(out, "sqlite: vacuum warning for {}: {}", db.display(), e); + } else { + vacuumed += 1; + } + } + let _ = writeln!(out, "sqlite: vacuumed {vacuumed} databases"); + Ok(()) +} + +fn state_clean( + runner: &dyn Runner, + config_home: &Path, + state_home: &Path, + trash_deps: &trash::TrashDeps, + out: &mut dyn Write, +) -> Result<()> { + if !state_home.is_dir() { + let _ = writeln!(out, "state: state directory not found"); + return Ok(()); + } + let now = std::time::SystemTime::now(); + let thirty_days = std::time::Duration::from_secs(30 * 86400); + let mut cleaned = 0; + clean_stale_recursive(runner, config_home, state_home, now, thirty_days, trash_deps, &mut cleaned)?; + let _ = writeln!(out, "state: cleaned {cleaned} stale files older than 30 days"); + Ok(()) +} + +fn clean_stale_recursive( + runner: &dyn Runner, + config_home: &Path, + dir: &Path, + now: std::time::SystemTime, + max_age: std::time::Duration, + trash_deps: &trash::TrashDeps, + cleaned: &mut usize, +) -> Result<()> { + let Ok(entries) = std::fs::read_dir(dir) else { + return Ok(()); + }; + for entry in entries.flatten() { + let p = entry.path(); + if let Ok(meta) = p.symlink_metadata() { + if meta.is_dir() && !meta.file_type().is_symlink() { + clean_stale_recursive(runner, config_home, &p, now, max_age, trash_deps, cleaned)?; + } else if meta.is_file() { + if let Ok(mtime) = meta.modified() { + if let Ok(age) = now.duration_since(mtime) { + if age >= max_age { + if trash::safe_delete_with( + runner, + config_home, + trash_deps, + &p, + false, + ).is_ok() { + *cleaned += 1; + } + } + } + } + } + } + } + Ok(()) +} + +fn autostart_audit(runner: &dyn Runner, config_home: &Path, out: &mut dyn Write) -> Result<()> { + let autostart_dir = config_home.join("autostart"); + if !autostart_dir.is_dir() { + let _ = writeln!(out, "autostart: no autostart directory found"); + return Ok(()); + } + let mut broken = Vec::new(); + let entries = std::fs::read_dir(&autostart_dir) + .map(|rd| rd.flatten().map(|e| e.path()).collect::>()) + .unwrap_or_default(); + for path in entries { + if path.extension().and_then(|s| s.to_str()) != Some("desktop") { + continue; + } + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; + for line in content.lines() { + let trimmed = line.trim(); + if let Some(exec) = trimmed.strip_prefix("Exec=") { + let bin = exec.split_whitespace().next().unwrap_or_default(); + let bin = bin.trim_matches('"').trim_matches('\''); + if bin.is_empty() { + continue; + } + let exists = if bin.starts_with('/') { + Path::new(bin).exists() + } else { + runner.look_path(bin).is_ok() + }; + if !exists { + broken.push(format!("{}: binary '{}' not found", path.display(), bin)); + } + break; + } + } + } + if broken.is_empty() { + let _ = writeln!(out, "autostart: all autostart desktop entries valid"); + } else { + let _ = writeln!(out, "autostart: found {} broken entries:", broken.len()); + for b in broken { + let _ = writeln!(out, " - {}", b); + } + } + Ok(()) +} + +fn services_audit(config_home: &Path, out: &mut dyn Write) -> Result<()> { + let mut broken = Vec::new(); + let dirs = [ + PathBuf::from("/etc/systemd/system"), + config_home.join("systemd/user"), + ]; + for dir in &dirs { + if !dir.is_dir() { + continue; + } + let Ok(entries) = std::fs::read_dir(dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if let Ok(meta) = path.symlink_metadata() { + if meta.file_type().is_symlink() && std::fs::metadata(&path).is_err() { + broken.push(path.display().to_string()); + } + } + } + } + if broken.is_empty() { + let _ = writeln!(out, "services: no broken unit symlinks found"); + } else { + let _ = writeln!(out, "services: found {} broken unit symlinks:", broken.len()); + for b in broken { + let _ = writeln!(out, " - {}", b); + } + } + Ok(()) +} + +fn permissions_audit(home: &Path, out: &mut dyn Write) -> Result<()> { + if !home.is_dir() { + return Ok(()); + } + let mut root_owned = Vec::new(); + audit_perms_dir(home, 1, 3, &mut root_owned); + if root_owned.is_empty() { + let _ = writeln!(out, "permissions: no root-owned files found in $HOME"); + } else { + let _ = writeln!(out, "permissions: found {} root-owned files in $HOME:", root_owned.len()); + for r in root_owned.iter().take(10) { + let _ = writeln!(out, " - {}", r); + } + if root_owned.len() > 10 { + let _ = writeln!(out, " ... and {} more", root_owned.len() - 10); + } + } + Ok(()) +} + +fn audit_perms_dir(dir: &Path, depth: usize, max_depth: usize, root_owned: &mut Vec) { + if depth > max_depth { + return; + } + use std::os::unix::fs::MetadataExt; + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if let Ok(meta) = path.symlink_metadata() { + if meta.uid() == 0 { + root_owned.push(path.display().to_string()); + } + if meta.is_dir() && !meta.file_type().is_symlink() { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default(); + if name == ".git" || name == "node_modules" || name == "target" || name == ".cargo" || name == ".rustup" { + continue; + } + audit_perms_dir(&path, depth + 1, max_depth, root_owned); + } + } + } +} + +fn disk_health_audit(runner: &dyn Runner, out: &mut dyn Write) -> Result<()> { + let mut probed = false; + if runner.look_path("smartctl").is_ok() { + let res = runner.run(&CommandSpec::new("smartctl", ["--scan"])); + if let Ok(output) = res { + if !output.stdout.is_empty() { + let _ = writeln!(out, "SMART devices detected:\n{}", output.stdout_lossy().trim()); + probed = true; + } + } + } + if !probed && runner.look_path("journalctl").is_ok() { + let res = runner.run(&CommandSpec::new( + "journalctl", + ["-k", "--priority=err", "-n", "10", "--no-pager"], + )); + if let Ok(output) = res { + let text = output.stdout_lossy(); + let fs_errors: Vec<&str> = text + .lines() + .filter(|l| l.contains("EXT4-fs") || l.contains("BTRFS") || l.contains("XFS") || l.contains("I/O error")) + .collect(); + if !fs_errors.is_empty() { + let _ = writeln!(out, "Filesystem alerts in kernel log:\n{}", fs_errors.join("\n")); + probed = true; + } + } + } + if !probed { + let _ = writeln!(out, "disk-health: no hardware/filesystem errors detected"); + } + Ok(()) +} + #[cfg(test)] mod tests { //! Port of `internal/optimize/step_test.go`. @@ -501,6 +1104,9 @@ mod tests { confirm: Arc::new(|_| panic!("confirm must not be reached")), config_home: root.to_path_buf(), data_home: root.join("data"), + home: root.to_path_buf(), + state_home: root.join("state"), + trash_deps: Rc::new(trash::TrashDeps::real()), no_oplog: true, } } @@ -512,7 +1118,24 @@ mod tests { // Port of TestStepIDs. #[test] fn step_ids_match_go_order() { - assert_eq!(step_ids(), vec!["apt", "journal", "caches"]); + assert_eq!( + step_ids(), + vec![ + "apt", + "journal", + "caches", + "dns", + "packages", + "systemd", + "flatpak", + "sqlite", + "state", + "autostart", + "services", + "permissions", + "disk-health", + ] + ); } // Port of TestOptimizeModelRecordsFailedAndSuccessfulSteps — the model's @@ -520,16 +1143,8 @@ mod tests { #[test] fn executor_records_failed_and_successful_steps() { let steps = vec![ - Step { - id: "bad", - desc: "bad", - run: Box::new(|_| msg("blocked")), - }, - Step { - id: "good", - desc: "good", - run: Box::new(|_| Ok(())), - }, + Step::new("bad", "bad", "bad", Box::new(|_| msg("blocked"))), + Step::new("good", "good", "good", Box::new(|_| Ok(()))), ]; let mut out = Vec::new(); let results = execute_steps(&steps, &[], &mut out); @@ -550,11 +1165,12 @@ mod tests { // Port of TestOptimizeModelRecordsSkippedStep — a skipped step never runs. #[test] fn executor_records_skipped_step() { - let steps = vec![Step { - id: "apt", - desc: "apt", - run: Box::new(|_| panic!("skipped step must not run")), - }]; + let steps = vec![Step::new( + "apt", + "apt", + "apt", + Box::new(|_| panic!("skipped step must not run")), + )]; let mut out = Vec::new(); let results = execute_steps(&steps, &["apt".to_string()], &mut out); assert_eq!(results[0].status, StepStatus::Skipped); @@ -625,18 +1241,14 @@ mod tests { let deps = test_deps(&root, runner.clone()); let opts = Options { auto_yes: true, - skip: vec![ - "apt".to_string(), - "journal".to_string(), - "caches".to_string(), - ], + skip: step_ids().into_iter().map(String::from).collect(), ..Options::default() }; let mut out = Vec::new(); let summary = run_with(&opts, &deps, &mut out).expect("all-skipped run"); assert_eq!( summary, - "Optimization complete — 0 succeeded, 0 failed, 3 skipped" + "Optimization complete — 0 succeeded, 0 failed, 13 skipped" ); assert!( runner.invocations().is_empty(), @@ -657,9 +1269,11 @@ mod tests { ))) }); let deps = test_deps(&root, runner.clone()); + let mut skip_ids = step_ids(); + skip_ids.retain(|&id| id != "caches"); let opts = Options { auto_yes: true, - skip: vec!["apt".to_string(), "journal".to_string()], + skip: skip_ids.into_iter().map(String::from).collect(), ..Options::default() }; let mut out = Vec::new(); @@ -784,7 +1398,7 @@ mod tests { assert_eq!(summary, "Dry run — nothing executed."); assert_eq!( String::from_utf8_lossy(&out), - "\n Optimize plan\n [run] apt-get update && apt-get autoremove --purge\n [skip] journalctl --vacuum-size=500M\n [run] update icon/mime/font caches\n Dry run — nothing executed.\n" + "\n Optimize plan\n [dry-run] sudo apt-get update && sudo apt-get autoremove --purge -y\n [skip] journal\n [dry-run] sudo update-mime-database /usr/share/mime && fc-cache -f\n [dry-run] resolvectl flush-caches\n [dry-run] sudo dpkg --configure -a && sudo apt-get install -f -y\n [dry-run] systemctl reset-failed && systemctl --user reset-failed\n [dry-run] flatpak uninstall --unused -y\n [dry-run] sqlite3 \"VACUUM;\"\n [dry-run] find ~/.local/state -type f -mtime +30 -delete\n [dry-run] audit ~/.config/autostart/*.desktop for missing Exec binaries\n [dry-run] audit broken symlinks in /etc/systemd/system and ~/.config/systemd/user\n [dry-run] audit files in $HOME owned by root\n [dry-run] smartctl --scan || journalctl -k --grep=EXT4-fs\n Dry run — nothing executed.\n" ); assert!(runner.invocations().is_empty()); fs::remove_dir_all(&root).ok(); diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 002d431..febd9b1 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -305,3 +305,102 @@ fn analyze_relative_path_dry_run_reports_absolute_target() { let _ = std::fs::remove_dir_all(&test_dir); } + +/// Phase 3: `mu optimize --dry-run` output contains exact shell command strings. +#[test] +fn optimize_dry_run_exact_commands() { + let o = mu(&["optimize", "--dry-run"]); + assert_eq!(o.status.code(), Some(0), "stderr: {}", stderr(&o)); + let out = stdout(&o); + assert!( + out.contains("[dry-run] sudo journalctl --vacuum-size=500M"), + "missing exact journal command: {out}" + ); + assert!( + out.contains("[dry-run] resolvectl flush-caches"), + "missing exact dns command: {out}" + ); + assert!( + out.contains("[dry-run] sudo apt-get update && sudo apt-get autoremove --purge -y"), + "missing exact apt command: {out}" + ); + assert!( + out.contains("[dry-run] sudo dpkg --configure -a && sudo apt-get install -f -y"), + "missing exact packages command: {out}" + ); + assert!( + out.contains("[dry-run] systemctl reset-failed && systemctl --user reset-failed"), + "missing exact systemd command: {out}" + ); +} + +/// Phase 3: `mu optimize --dry-run --skip=apt,dns,packages` marks skipped steps with [skip]. +#[test] +fn optimize_dry_run_skip_steps() { + let o = mu(&["optimize", "--dry-run", "--skip=apt,dns,packages"]); + assert_eq!(o.status.code(), Some(0), "stderr: {}", stderr(&o)); + let out = stdout(&o); + assert!( + out.contains("[skip] apt"), + "missing [skip] apt: {out}" + ); + assert!( + out.contains("[skip] dns"), + "missing [skip] dns: {out}" + ); + assert!( + out.contains("[skip] packages"), + "missing [skip] packages: {out}" + ); + assert!( + out.contains("[dry-run] sudo journalctl --vacuum-size=500M"), + "unskipped step should show dry-run: {out}" + ); +} + +/// Phase 3: Sentinel immutability test on ~/.local/state/sentinel-test/stale.log. +#[test] +fn optimize_dry_run_sentinel_immutability() { + let home = std::env::var_os("HOME").map(std::path::PathBuf::from).unwrap_or_default(); + let state_dir = home.join(".local/state/sentinel-test"); + let _ = std::fs::create_dir_all(&state_dir); + let sentinel_file = state_dir.join("stale.log"); + std::fs::write(&sentinel_file, "sentinel stale state data").unwrap(); + + let o = mu(&["optimize", "--dry-run"]); + assert_eq!(o.status.code(), Some(0), "stderr: {}", stderr(&o)); + assert!( + sentinel_file.exists(), + "sentinel file was deleted during dry-run!" + ); + + let _ = std::fs::remove_dir_all(&state_dir); +} + +/// Phase 3: `mu clean --dry-run --include=dev-cache` output includes "Developer CLI Caches". +#[test] +fn clean_dry_run_include_dev_cache() { + let o = mu(&["clean", "--dry-run", "--include=dev-cache"]); + assert_eq!(o.status.code(), Some(0), "stderr: {}", stderr(&o)); + let out = stdout(&o); + assert!( + out.contains("Developer CLI Caches"), + "missing 'Developer CLI Caches' in output: {out}" + ); +} + +/// Phase 3: `mu clean --dry-run` with all 4 opt-in targets. +#[test] +fn clean_dry_run_all_opt_in_targets() { + let o = mu(&[ + "clean", + "--dry-run", + "--include=dev-cache,podman,orphaned-app-data,orphaned-services", + ]); + assert_eq!(o.status.code(), Some(0), "stderr: {}", stderr(&o)); + let out = stdout(&o); + assert!(out.contains("Developer CLI Caches"), "missing dev-cache: {out}"); + assert!(out.contains("Podman System Cache"), "missing podman: {out}"); + assert!(out.contains("Orphaned Application Data"), "missing orphaned-app-data: {out}"); + assert!(out.contains("Orphaned System Services"), "missing orphaned-services: {out}"); +} From 89f12c5dee8d04f156789a183f13b3192160aaf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ba=20R=E1=BB=8Di?= Date: Wed, 14 Oct 2026 17:08:36 +0700 Subject: [PATCH 04/17] feat(parity): implement mu history, mu purge, and mu installer (Phase 4) - Implement `mu history` reading operations.log with tabular and --json formatting - Implement `mu purge` safely trashing project build artifacts with --paths and --dry-run - Implement `mu installer` inspecting and removing .deb/.AppImage/.iso/archive packages - Add post-clean advisory hint in `mu clean` when build artifacts exceed 2 GB - Update golden help fixtures and add cli integration tests --- src/clean/mod.rs | 29 +++ src/cli.rs | 259 ++++++++++++++++++++++++- src/history.rs | 248 +++++++++++++++++++++++ src/installer/inspect.rs | 177 +++++++++++++++++ src/installer/mod.rs | 151 ++++++++++++++ src/installer/model.rs | 43 ++++ src/installer/scanner.rs | 165 ++++++++++++++++ src/main.rs | 6 + src/purge/mod.rs | 156 +++++++++++++++ src/purge/model.rs | 29 +++ src/purge/scanner.rs | 233 ++++++++++++++++++++++ tests/cli_integration.rs | 134 +++++++++++++ tests/golden/help-history.exitcode | 1 + tests/golden/help-history.stderr.txt | 0 tests/golden/help-history.txt | 12 ++ tests/golden/help-installer.exitcode | 1 + tests/golden/help-installer.stderr.txt | 0 tests/golden/help-installer.txt | 13 ++ tests/golden/help-purge.exitcode | 1 + tests/golden/help-purge.stderr.txt | 0 tests/golden/help-purge.txt | 14 ++ tests/golden/help.txt | 3 + 22 files changed, 1673 insertions(+), 2 deletions(-) create mode 100644 src/history.rs create mode 100644 src/installer/inspect.rs create mode 100644 src/installer/mod.rs create mode 100644 src/installer/model.rs create mode 100644 src/installer/scanner.rs create mode 100644 src/purge/mod.rs create mode 100644 src/purge/model.rs create mode 100644 src/purge/scanner.rs create mode 100644 tests/golden/help-history.exitcode create mode 100644 tests/golden/help-history.stderr.txt create mode 100644 tests/golden/help-history.txt create mode 100644 tests/golden/help-installer.exitcode create mode 100644 tests/golden/help-installer.stderr.txt create mode 100644 tests/golden/help-installer.txt create mode 100644 tests/golden/help-purge.exitcode create mode 100644 tests/golden/help-purge.stderr.txt create mode 100644 tests/golden/help-purge.txt diff --git a/src/clean/mod.rs b/src/clean/mod.rs index b4ab35c..56d941f 100644 --- a/src/clean/mod.rs +++ b/src/clean/mod.rs @@ -340,9 +340,38 @@ fn run_plain( let freed = execute(&mut r, &results, opts, deps)?; r.summary(&format!("All done — freed {}", size::human_size(freed)))?; + check_project_artifacts_hint(&mut r)?; Ok(format!("Freed {}", size::human_size(freed))) } +/// Check default project directories for heavy build artifacts (> 2 GB) and print an advisory hint. +fn check_project_artifacts_hint(r: &mut runtext::Run<'_>) -> Result<()> { + let home = xdg::home_dir(); + let candidates = [ + home.join("Projects"), + home.join("Code"), + home.join("src"), + home.join("Development"), + ]; + let mut total_bytes: u64 = 0; + for dir in &candidates { + if dir.is_dir() { + let artifacts = crate::purge::scanner::scan_root(dir, false); + for a in artifacts { + total_bytes = total_bytes.saturating_add(a.size); + } + } + } + + const TWO_GIGABYTES: u64 = 2 * 1024 * 1024 * 1024; + if total_bytes > TWO_GIGABYTES { + r.line(format_args!( + "\n💡 Hint: Found {} of project build artifacts in code directories. Run 'mu purge' to reclaim space.", + size::human_size(total_bytes as i64) + ))?; + } + Ok(()) +} /// `execute` — run each target's `Execute` in order, logging per-target /// outcomes and warning on stderr for failures. Returns the summed size of /// the successful targets; one or more failures yield diff --git a/src/cli.rs b/src/cli.rs index e130f4d..ac2e123 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -43,7 +43,10 @@ Available Commands: clean Free disk space by removing caches, logs, and old packages completion Generate the autocompletion script for the specified shell help Help about any command + history View recent operations and deletion log + installer Find and remove downloaded installer packages (.deb, .AppImage, .iso) optimize Run system maintenance: apt autoremove, journal vacuum, cache refresh + purge Safely clean heavy project build artifacts (node_modules, target, .venv) status Live system dashboard: CPU, RAM, disk, network, health score uninstall Remove apps and all their config, cache, and data remnants @@ -372,6 +375,53 @@ Usage: Flags: -h, --help help for help +Global Flags: + --debug Enable verbose debug logging +"; +const HISTORY_HELP: &str = "\ +View recent operations and deletion log from operations.log. + +Usage: + mu history [flags] + +Flags: + -h, --help help for history + --json Output operations log as a JSON array + --limit int Number of recent operations to display (default 20) + +Global Flags: + --debug Enable verbose debug logging +"; + +const INSTALLER_HELP: &str = "\ +Find and remove downloaded installer packages (.deb, .AppImage, .iso). + +Usage: + mu installer [flags] + +Flags: + --dry-run Preview actions without making changes + -h, --help help for installer + --paths strings Specific directories to scan + -y, --yes Skip confirmation prompt + +Global Flags: + --debug Enable verbose debug logging +"; + +const PURGE_HELP: &str = "\ +Safely clean heavy project build artifacts (node_modules, target, .venv). + +Usage: + mu purge [flags] + +Flags: + --dry-run Preview actions without making changes + -h, --help help for purge + --include-empty Include empty build artifact directories + --paths strings Specific project directories to scan + -y, --yes Skip confirmation prompt + Global Flags: --debug Enable verbose debug logging "; @@ -384,7 +434,10 @@ const REAL_COMMANDS: &[&str] = &[ "analyze", "audit", "clean", + "history", + "installer", "optimize", + "purge", "status", "uninstall", ]; @@ -394,7 +447,10 @@ fn cmd_help(name: &str) -> Option<&'static str> { "analyze" => Some(ANALYZE_HELP), "audit" => Some(AUDIT_HELP), "clean" => Some(CLEAN_HELP), + "history" => Some(HISTORY_HELP), + "installer" => Some(INSTALLER_HELP), "optimize" => Some(OPTIMIZE_HELP), + "purge" => Some(PURGE_HELP), "status" => Some(STATUS_HELP), "uninstall" => Some(UNINSTALL_HELP), _ => None, @@ -448,6 +504,21 @@ enum Command { Uninstall { dry_run: bool, }, + History { + limit: usize, + json: bool, + }, + Purge { + paths: Vec, + dry_run: bool, + yes: bool, + include_empty: bool, + }, + Installer { + paths: Vec, + dry_run: bool, + yes: bool, + }, } // --------------------------------------------------------------------------- @@ -556,6 +627,38 @@ pub fn run() -> ! { Command::Uninstall { dry_run } => { std::process::exit(uninstall::run(&uninstall::Options { dry_run, debug })) } + Command::History { limit, json } => { + std::process::exit(crate::history::run(&crate::history::Options { + limit, + json, + })); + } + Command::Purge { + paths, + dry_run, + yes, + include_empty, + } => { + std::process::exit(crate::purge::run(&crate::purge::Options { + paths, + dry_run, + auto_yes: yes, + include_empty, + debug, + })); + } + Command::Installer { + paths, + dry_run, + yes, + } => { + std::process::exit(crate::installer::run(&crate::installer::Options { + paths, + dry_run, + auto_yes: yes, + debug, + })); + } } } } @@ -676,6 +779,24 @@ const F_NODESC: FlagSpec = FlagSpec { takes_value: false, display: "--no-descriptions", }; +const F_LIMIT: FlagSpec = FlagSpec { + long: "limit", + short: None, + takes_value: true, + display: "--limit", +}; +const F_PATHS: FlagSpec = FlagSpec { + long: "paths", + short: None, + takes_value: true, + display: "--paths", +}; +const F_INCLUDE_EMPTY: FlagSpec = FlagSpec { + long: "include-empty", + short: None, + takes_value: false, + display: "--include-empty", +}; const ROOT_FLAGS: &[FlagSpec] = &[F_DEBUG, F_HELP, F_VERSION]; const HELP_FLAGS: &[FlagSpec] = &[F_DEBUG, F_HELP]; @@ -690,6 +811,9 @@ fn cmd_flagset(name: &str) -> &'static [FlagSpec] { "optimize" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_YES, F_SKIP], "audit" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_JSON, F_REPORT, F_INCLUDE], "uninstall" => &[F_DEBUG, F_HELP, F_DRY_RUN], + "history" => &[F_DEBUG, F_HELP, F_JSON, F_LIMIT], + "installer" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_YES, F_PATHS], + "purge" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_YES, F_PATHS, F_INCLUDE_EMPTY], _ => &[], } } @@ -926,10 +1050,13 @@ const SUGGEST_COMMANDS: &[&str] = &[ "analyze", "audit", "clean", - "uninstall", + "completion", + "history", + "installer", "optimize", + "purge", "status", - "completion", + "uninstall", ]; /// `unknown command "X" for "mu"` + `Did you mean this?` suggestions @@ -1031,6 +1158,43 @@ fn exec_subcommand(name: &str, args: &[String]) -> Outcome { "uninstall" => Command::Uninstall { dry_run: p.get("dry-run"), }, + "history" => { + let limit = if let Some(s) = p.vals("limit").first() { + match s.parse::() { + Ok(l) => l, + Err(_) => { + return perr(format!( + "invalid argument \"{s}\" for \"--limit\" flag: strconv.ParseInt: parsing \"{s}\": invalid syntax\n" + )); + } + } + } else { + crate::history::DEFAULT_LIMIT + }; + Command::History { + limit, + json: p.get("json"), + } + } + "purge" => Command::Purge { + paths: p + .vals("paths") + .iter() + .map(std::path::PathBuf::from) + .collect(), + dry_run: p.get("dry-run"), + yes: p.get("yes"), + include_empty: p.get("include-empty"), + }, + "installer" => Command::Installer { + paths: p + .vals("paths") + .iter() + .map(std::path::PathBuf::from) + .collect(), + dry_run: p.get("dry-run"), + yes: p.get("yes"), + }, _ => unreachable!(), }; Outcome::Run { @@ -1873,4 +2037,95 @@ mod tests { include_str!("../tests/golden/help-uninstall.txt") ); } + #[test] + fn golden_history_help_byte_exact() { + assert_eq!( + HISTORY_HELP, + include_str!("../tests/golden/help-history.txt") + ); + } + + #[test] + fn golden_installer_help_byte_exact() { + assert_eq!( + INSTALLER_HELP, + include_str!("../tests/golden/help-installer.txt") + ); + } + + #[test] + fn golden_purge_help_byte_exact() { + assert_eq!(PURGE_HELP, include_str!("../tests/golden/help-purge.txt")); + } + + #[test] + fn history_flag_parsing() { + match parse(&args(&["history", "--json", "--limit", "50"])) { + Outcome::Run { cmd, .. } => match cmd { + Command::History { limit, json } => { + assert_eq!(limit, 50); + assert!(json); + } + _ => panic!("expected History"), + }, + _ => panic!("expected Run"), + } + } + + #[test] + fn purge_flag_parsing() { + match parse(&args(&[ + "purge", + "--dry-run", + "-y", + "--include-empty", + "--paths=/tmp/a,/tmp/b", + ])) { + Outcome::Run { cmd, .. } => match cmd { + Command::Purge { + paths, + dry_run, + yes, + include_empty, + } => { + assert_eq!( + paths, + vec![ + std::path::PathBuf::from("/tmp/a"), + std::path::PathBuf::from("/tmp/b") + ] + ); + assert!(dry_run); + assert!(yes); + assert!(include_empty); + } + _ => panic!("expected Purge"), + }, + _ => panic!("expected Run"), + } + } + + #[test] + fn installer_flag_parsing() { + match parse(&args(&[ + "installer", + "--dry-run", + "-y", + "--paths=/tmp/install", + ])) { + Outcome::Run { cmd, .. } => match cmd { + Command::Installer { + paths, + dry_run, + yes, + } => { + assert_eq!(paths, vec![std::path::PathBuf::from("/tmp/install")]); + assert!(dry_run); + assert!(yes); + } + _ => panic!("expected Installer"), + }, + _ => panic!("expected Run"), + } + } } diff --git a/src/history.rs b/src/history.rs new file mode 100644 index 0000000..561b1b3 --- /dev/null +++ b/src/history.rs @@ -0,0 +1,248 @@ +//! Operational history viewer — inspects `$XDG_DATA_HOME/mu/operations.log`. + +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::xdg; + +/// Default and maximum limit for history queries. +pub const DEFAULT_LIMIT: usize = 20; +pub const MAX_LIMIT: usize = 200; + +/// A single entry in the operations log. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct LogEntry { + pub timestamp: String, + pub action: String, + pub outcome: String, + pub target: String, +} + +/// Options for `mu history`. +#[derive(Debug, Clone, Default)] +pub struct Options { + pub limit: usize, + pub json: bool, +} + +/// Parse a single line from `operations.log`. +/// Format: `{rfc3339} {action:<12} {outcome:<8} {target}` +pub fn parse_log_line(line: &str) -> Option { + let line = line.trim_end_matches(['\r', '\n']); + if line.trim().is_empty() { + return None; + } + + // Split on whitespace boundaries for the first 3 tokens: timestamp, action, outcome. + // The target is the remainder of the line, which may contain spaces. + let mut parts = line.split_whitespace(); + let timestamp = parts.next()?; + let action = parts.next()?; + let outcome = parts.next()?; + + // To accurately extract target, find where outcome ends in the original line. + let outcome_offset = line.find(outcome)? + outcome.len(); + let target = line[outcome_offset..].trim_start().to_string(); + + Some(LogEntry { + timestamp: timestamp.to_string(), + action: action.to_string(), + outcome: outcome.to_string(), + target, + }) +} + +/// Returns the path to `operations.log` under the XDG data home. +pub fn log_path() -> PathBuf { + xdg::data_home().join("mu").join("operations.log") +} + +/// Read up to `limit` entries from the given log file path. +/// Clamps `limit` between 1 and `MAX_LIMIT` (defaulting to `DEFAULT_LIMIT` if 0). +pub fn read_history_from(path: &Path, limit: usize) -> Vec { + let limit = if limit == 0 { + DEFAULT_LIMIT + } else { + limit.min(MAX_LIMIT) + }; + + let file = match File::open(path) { + Ok(f) => f, + Err(_) => return Vec::new(), + }; + + let reader = BufReader::new(file); + let mut entries = Vec::new(); + + for line in reader.lines().map_while(Result::ok) { + if let Some(entry) = parse_log_line(&line) { + entries.push(entry); + } + } + + if entries.len() > limit { + let start = entries.len() - limit; + entries.drain(..start); + } + + entries +} + +/// Format log entries in a clean tabular view. +pub fn format_table(entries: &[LogEntry]) -> String { + if entries.is_empty() { + return "No recent operations recorded.\n".to_string(); + } + + let mut out = String::new(); + // Headers aligned with columns + out.push_str(&format!( + "{:<25} {:<12} {:<9} {}\n", + "TIMESTAMP", "ACTION", "OUTCOME", "TARGET" + )); + + for e in entries { + out.push_str(&format!( + "{:<25} {:<12} {:<9} {}\n", + e.timestamp, e.action, e.outcome, e.target + )); + } + + out +} + +/// Entry point for `mu history`. +pub fn run(opts: &Options) -> i32 { + let path = log_path(); + let entries = read_history_from(&path, opts.limit); + + if opts.json { + match serde_json::to_string_pretty(&entries) { + Ok(json_str) => { + println!("{json_str}"); + 0 + } + Err(e) => { + eprintln!("error: failed to serialize history to JSON: {e}"); + 1 + } + } + } else { + print!("{}", format_table(&entries)); + 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use std::path::PathBuf; + + fn tempdir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!( + "mu-test-history-{}-{}-{}", + tag, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn parse_valid_log_line() { + let line = "2026-10-14T10:00:00Z trash success /home/user/test file.txt"; + let entry = parse_log_line(line).expect("should parse"); + assert_eq!(entry.timestamp, "2026-10-14T10:00:00Z"); + assert_eq!(entry.action, "trash"); + assert_eq!(entry.outcome, "success"); + assert_eq!(entry.target, "/home/user/test file.txt"); + } + + #[test] + fn parse_empty_or_malformed_lines() { + assert!(parse_log_line("").is_none()); + assert!(parse_log_line(" ").is_none()); + assert!(parse_log_line("2026-10-14T10:00:00Z only_action").is_none()); + } + + #[test] + fn read_history_with_limit_and_order() { + let dir = tempdir("order"); + let path = dir.join("operations.log"); + let mut file = File::create(&path).expect("create"); + + for i in 1..=10 { + writeln!( + file, + "2026-10-14T10:00:{:02}Z clean success /tmp/target_{}", + i, i + ) + .expect("write"); + } + drop(file); + + let entries = read_history_from(&path, 3); + assert_eq!(entries.len(), 3); + assert_eq!(entries[0].target, "/tmp/target_8"); + assert_eq!(entries[1].target, "/tmp/target_9"); + assert_eq!(entries[2].target, "/tmp/target_10"); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn read_history_clamped_max_limit() { + let dir = tempdir("clamped"); + let path = dir.join("operations.log"); + let mut file = File::create(&path).expect("create"); + + for i in 1..=250 { + writeln!( + file, + "2026-10-14T10:00:00Z trash success /tmp/item_{}", + i + ) + .expect("write"); + } + drop(file); + + // Requested 300, clamped to MAX_LIMIT (200) + let entries = read_history_from(&path, 300); + assert_eq!(entries.len(), MAX_LIMIT); + assert_eq!(entries[0].target, "/tmp/item_51"); + assert_eq!(entries[199].target, "/tmp/item_250"); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn read_history_nonexistent_file() { + let path = Path::new("/tmp/nonexistent_mu_test_operations.log"); + let entries = read_history_from(path, 10); + assert!(entries.is_empty()); + } + + #[test] + fn format_table_output() { + let entries = vec![LogEntry { + timestamp: "2026-10-14T10:00:00Z".to_string(), + action: "trash".to_string(), + outcome: "success".to_string(), + target: "/tmp/sample".to_string(), + }]; + let table = format_table(&entries); + assert!(table.contains("TIMESTAMP")); + assert!(table.contains("ACTION")); + assert!(table.contains("OUTCOME")); + assert!(table.contains("TARGET")); + assert!(table.contains("/tmp/sample")); + } +} diff --git a/src/installer/inspect.rs b/src/installer/inspect.rs new file mode 100644 index 0000000..9218881 --- /dev/null +++ b/src/installer/inspect.rs @@ -0,0 +1,177 @@ +//! Archive content inspector for ZIP and tar.gz installer detection. + +use std::fs::File; +use std::io::{Read, Seek, SeekFrom}; +use std::path::Path; +use std::time::Duration; + +use crate::runner::{CommandSpec, ProcessRunner, Runner}; + +/// Check if an entry name within an archive indicates an installer or executable binary. +pub fn is_installer_entry(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + let basename = Path::new(&lower) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(&lower); + + // Common installer extensions + if basename.ends_with(".deb") + || basename.ends_with(".appimage") + || basename.ends_with(".sh") + || basename.ends_with(".bin") + || basename.ends_with(".run") + || basename.ends_with(".exe") + || basename.ends_with(".pkg") + { + return true; + } + + // Common installer filenames + if basename.starts_with("install") || basename.starts_with("setup") || basename == "configure" { + return true; + } + + false +} + +/// Inspect a ZIP archive's central directory to determine if it contains installer files. +/// Pure standard library implementation (no heavy crate dependencies). +pub fn inspect_zip(path: &Path) -> bool { + let mut file = match File::open(path) { + Ok(f) => f, + Err(_) => return false, + }; + + let file_len = match file.metadata() { + Ok(m) => m.len(), + Err(_) => return false, + }; + + if file_len < 22 { + return false; + } + + // Search backwards for the End of Central Directory (EOCD) signature: 0x06054b50 + let search_len = std::cmp::min(file_len, 65536 + 22) as usize; + let mut buffer = vec![0u8; search_len]; + let offset = file_len - search_len as u64; + + if file.seek(SeekFrom::Start(offset)).is_err() { + return false; + } + if file.read_exact(&mut buffer).is_err() { + return false; + } + + // Find EOCD marker: PK\x05\x06 + let mut eocd_rel_pos = None; + for i in (0..search_len.saturating_sub(3)).rev() { + if buffer[i] == 0x50 + && buffer[i + 1] == 0x4b + && buffer[i + 2] == 0x05 + && buffer[i + 3] == 0x06 + { + eocd_rel_pos = Some(i); + break; + } + } + + let eocd_pos = match eocd_rel_pos { + Some(pos) => pos, + None => return false, + }; + + if eocd_pos + 22 > search_len { + return false; + } + + let total_entries = u16::from_le_bytes([buffer[eocd_pos + 10], buffer[eocd_pos + 11]]) as usize; + let cd_offset = u32::from_le_bytes([ + buffer[eocd_pos + 16], + buffer[eocd_pos + 17], + buffer[eocd_pos + 18], + buffer[eocd_pos + 19], + ]) as u64; + + if file.seek(SeekFrom::Start(cd_offset)).is_err() { + return false; + } + + // Read central directory records + let mut header = [0u8; 46]; + for _ in 0..total_entries { + if file.read_exact(&mut header).is_err() { + break; + } + + // Check Central Directory Header magic: PK\x01\x02 + if header[0] != 0x50 || header[1] != 0x4b || header[2] != 0x01 || header[3] != 0x02 { + break; + } + + let name_len = u16::from_le_bytes([header[28], header[29]]) as usize; + let extra_len = u16::from_le_bytes([header[30], header[31]]) as usize; + let comment_len = u16::from_le_bytes([header[32], header[33]]) as usize; + + let mut name_buf = vec![0u8; name_len]; + if file.read_exact(&mut name_buf).is_err() { + break; + } + + let entry_name = String::from_utf8_lossy(&name_buf); + if is_installer_entry(&entry_name) { + return true; + } + + // Skip extra field and comment + if file + .seek(SeekFrom::Current((extra_len + comment_len) as i64)) + .is_err() + { + break; + } + } + + false +} + +/// Inspect a tar.gz / tgz archive by listing filenames using `tar -tf`. +pub fn inspect_targz(path: &Path) -> bool { + let runner = ProcessRunner; + let mut spec = CommandSpec::new("tar", ["-tf".as_ref(), path.as_os_str()]); + spec.timeout = Some(Duration::from_secs(2)); + + let output = match runner.run(&spec) { + Ok(out) => out, + Err(_) => return false, + }; + + let stdout_str = String::from_utf8_lossy(&output.stdout); + for line in stdout_str.lines() { + let trimmed = line.trim(); + if !trimmed.is_empty() && is_installer_entry(trimmed) { + return true; + } + } + + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_installer_entry() { + assert!(is_installer_entry("setup.sh")); + assert!(is_installer_entry("sub/dir/install.bin")); + assert!(is_installer_entry("package.deb")); + assert!(is_installer_entry("app.AppImage")); + assert!(is_installer_entry("setup.exe")); + assert!(is_installer_entry("installer")); + assert!(!is_installer_entry("readme.txt")); + assert!(!is_installer_entry("image.png")); + assert!(!is_installer_entry("document.pdf")); + } +} diff --git a/src/installer/mod.rs b/src/installer/mod.rs new file mode 100644 index 0000000..da2da33 --- /dev/null +++ b/src/installer/mod.rs @@ -0,0 +1,151 @@ +//! Package installer cleanup command — discovers and removes .deb, .AppImage, .iso, and archive installers. + +pub mod inspect; +pub mod model; +pub mod scanner; + +use crate::oplog; +use crate::size; +use crate::trash; + +pub use model::Options; + +/// Entry point for `mu installer`. +pub fn run(opts: &Options) -> i32 { + let roots = scanner::resolve_installer_roots(&opts.paths); + if roots.is_empty() { + println!("No installer directories found to scan."); + return 0; + } + + let mut all_packages = Vec::new(); + for root in &roots { + let pkgs = scanner::scan_root(root); + all_packages.extend(pkgs); + } + + if all_packages.is_empty() { + println!("No installer packages found."); + return 0; + } + + let total_bytes: u64 = all_packages.iter().map(|p| p.size).sum(); + + println!( + "Found {} installer package(s) (total {}):", + all_packages.len(), + size::human_size(total_bytes as i64) + ); + for p in &all_packages { + println!( + " {:<12} {:<10} {}", + p.kind.as_str(), + size::human_size(p.size as i64), + p.path.display() + ); + } + + if opts.dry_run { + println!("\nThis is a DRY RUN. No files will be deleted."); + println!( + "Dry run — {} reclaimable across {} package(s)", + size::human_size(total_bytes as i64), + all_packages.len() + ); + for p in &all_packages { + oplog::log_outcome("installer", &p.path.to_string_lossy(), "dry-run"); + } + return 0; + } + + if !opts.auto_yes { + println!("\nNon-interactive: pass --yes to confirm, or --dry-run to preview."); + println!("Aborted."); + return 0; + } + + // Execution: route strictly through trash::safe_delete + let mut removed_count = 0; + let mut freed_bytes = 0; + for p in &all_packages { + match trash::safe_delete(&p.path, false) { + Ok(()) => { + oplog::log_outcome("installer", &p.path.to_string_lossy(), "success"); + removed_count += 1; + freed_bytes += p.size; + } + Err(e) => { + oplog::log_outcome("installer", &p.path.to_string_lossy(), "failure"); + eprintln!(" warn: failed to trash {}: {}", p.path.display(), e); + } + } + } + + println!( + "\nAll done — removed {} installer package(s) (freed {})", + removed_count, + size::human_size(freed_bytes as i64) + ); + 0 +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::{self, File}; + use std::path::PathBuf; + + fn tempdir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!( + "mu-test-installer-exec-{}-{}-{}", + tag, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn installer_dry_run_leaves_files_intact() { + let root = tempdir("dry"); + let dummy = root.join("dummy.deb"); + File::create(&dummy).unwrap(); + + let opts = Options { + paths: vec![root.clone()], + dry_run: true, + auto_yes: false, + debug: false, + }; + + let code = run(&opts); + assert_eq!(code, 0); + assert!(dummy.exists(), "dry run must leave installer file intact"); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn installer_aborts_without_auto_yes() { + let root = tempdir("abort"); + let dummy = root.join("app.AppImage"); + File::create(&dummy).unwrap(); + + let opts = Options { + paths: vec![root.clone()], + dry_run: false, + auto_yes: false, + debug: false, + }; + + let code = run(&opts); + assert_eq!(code, 0); + assert!(dummy.exists(), "aborted installer must not delete anything"); + + let _ = fs::remove_dir_all(&root); + } +} diff --git a/src/installer/model.rs b/src/installer/model.rs new file mode 100644 index 0000000..4aa7460 --- /dev/null +++ b/src/installer/model.rs @@ -0,0 +1,43 @@ +//! Data models for package installer discovery and removal. + +use std::path::PathBuf; + +/// Package format / installer type. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PackageKind { + Deb, + AppImage, + Iso, + TarGz, + Zip, +} + +impl PackageKind { + pub fn as_str(&self) -> &'static str { + match self { + PackageKind::Deb => ".deb", + PackageKind::AppImage => ".AppImage", + PackageKind::Iso => ".iso", + PackageKind::TarGz => ".tar.gz", + PackageKind::Zip => ".zip", + } + } +} + +/// A discovered installer package. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstallerPackage { + pub path: PathBuf, + pub name: String, + pub kind: PackageKind, + pub size: u64, +} + +/// Options for `mu installer`. +#[derive(Debug, Clone, Default)] +pub struct Options { + pub paths: Vec, + pub dry_run: bool, + pub auto_yes: bool, + pub debug: bool, +} diff --git a/src/installer/scanner.rs b/src/installer/scanner.rs new file mode 100644 index 0000000..04957cf --- /dev/null +++ b/src/installer/scanner.rs @@ -0,0 +1,165 @@ +//! Scanner for finding installer packages in download directories. + +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::installer::inspect::{inspect_targz, inspect_zip}; +use crate::installer::model::{InstallerPackage, PackageKind}; +use crate::paths; +use crate::xdg; + +/// Resolve roots to scan for installer packages. +pub fn resolve_installer_roots(configured: &[PathBuf]) -> Vec { + if !configured.is_empty() { + return configured + .iter() + .map(|p| { + if p.is_absolute() { + p.clone() + } else if let Ok(canon) = fs::canonicalize(p) { + canon + } else { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from("/")) + .join(p) + } + }) + .collect(); + } + + let home = xdg::home_dir(); + let candidates = ["Downloads", "Desktop", "Documents"]; + candidates + .iter() + .map(|d| home.join(d)) + .filter(|p| p.is_dir()) + .collect() +} + +/// Scan a root directory for installer packages. +pub fn scan_root(root: &Path) -> Vec { + let mut packages = Vec::new(); + + if paths::validate_cleanup_root(root).is_err() { + return packages; + } + + walk_installer_dir(root, root, 0, &mut packages); + packages +} + +fn walk_installer_dir( + root: &Path, + current: &Path, + depth: usize, + packages: &mut Vec, +) { + if depth > 3 { + return; + } + + let entries = match fs::read_dir(current) { + Ok(e) => e, + Err(_) => return, + }; + + for entry in entries.flatten() { + let path = entry.path(); + let file_type = match entry.file_type() { + Ok(ft) => ft, + Err(_) => continue, + }; + + if file_type.is_symlink() { + continue; + } + + let name = match path.file_name().and_then(|n| n.to_str()) { + Some(n) => n, + None => continue, + }; + + if name.starts_with('.') { + continue; + } + + if file_type.is_dir() { + walk_installer_dir(root, &path, depth + 1, packages); + continue; + } + + // File candidate + if paths::validate_cleanup_candidate(root, &path).is_err() { + continue; + } + + let lower = name.to_ascii_lowercase(); + let kind = if lower.ends_with(".deb") { + Some(PackageKind::Deb) + } else if lower.ends_with(".appimage") { + Some(PackageKind::AppImage) + } else if lower.ends_with(".iso") { + Some(PackageKind::Iso) + } else if (lower.ends_with(".tar.gz") || lower.ends_with(".tgz")) && inspect_targz(&path) { + Some(PackageKind::TarGz) + } else if lower.ends_with(".zip") && inspect_zip(&path) { + Some(PackageKind::Zip) + } else { + None + }; + + if let Some(kind) = kind { + let size = entry.metadata().map(|m| m.len()).unwrap_or(0); + packages.push(InstallerPackage { + path: path.clone(), + name: name.to_string(), + kind, + size, + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::File; + + fn tempdir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!( + "mu-test-installer-scan-{}-{}-{}", + tag, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn scan_finds_deb_and_appimage() { + let root = tempdir("scan"); + let deb = root.join("app_1.0.0_amd64.deb"); + let appimage = root.join("editor.AppImage"); + let iso = root.join("ubuntu.iso"); + let text = root.join("notes.txt"); + + File::create(&deb).unwrap(); + File::create(&appimage).unwrap(); + File::create(&iso).unwrap(); + File::create(&text).unwrap(); + + let pkgs = scan_root(&root); + assert_eq!(pkgs.len(), 3); + let names: Vec<&str> = pkgs.iter().map(|p| p.name.as_str()).collect(); + assert!(names.contains(&"app_1.0.0_amd64.deb")); + assert!(names.contains(&"editor.AppImage")); + assert!(names.contains(&"ubuntu.iso")); + assert!(!names.contains(&"notes.txt")); + + let _ = fs::remove_dir_all(&root); + } +} diff --git a/src/main.rs b/src/main.rs index 01d688f..e9f7c0d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,12 +13,18 @@ mod config; #[allow(dead_code)] mod error; #[allow(dead_code)] +mod history; +#[allow(dead_code)] +mod installer; +#[allow(dead_code)] mod oplog; #[allow(dead_code)] mod optimize; #[allow(dead_code)] mod paths; #[allow(dead_code)] +mod purge; +#[allow(dead_code)] mod runner; mod runtext; #[allow(dead_code)] diff --git a/src/purge/mod.rs b/src/purge/mod.rs new file mode 100644 index 0000000..9e534e6 --- /dev/null +++ b/src/purge/mod.rs @@ -0,0 +1,156 @@ +//! Project artifact purge command — safely cleans heavy build artifacts. + +pub mod model; +pub mod scanner; + +use crate::oplog; +use crate::size; +use crate::trash; + +pub use model::Options; + +/// Entry point for `mu purge`. +pub fn run(opts: &Options) -> i32 { + let roots = scanner::resolve_scan_roots(&opts.paths); + if roots.is_empty() { + println!("No project directories found to scan."); + return 0; + } + + let mut all_artifacts = Vec::new(); + for root in &roots { + let artifacts = scanner::scan_root(root, opts.include_empty); + all_artifacts.extend(artifacts); + } + + if all_artifacts.is_empty() { + println!("No project build artifacts found."); + return 0; + } + + let total_bytes: u64 = all_artifacts.iter().map(|a| a.size).sum(); + + println!( + "Found {} project build artifact(s) (total {}):", + all_artifacts.len(), + size::human_size(total_bytes as i64) + ); + for a in &all_artifacts { + println!( + " {:<16} {:<10} {}", + a.kind, + size::human_size(a.size as i64), + a.path.display() + ); + } + + if opts.dry_run { + println!("\nThis is a DRY RUN. No files will be deleted."); + println!( + "Dry run — {} reclaimable across {} artifact(s)", + size::human_size(total_bytes as i64), + all_artifacts.len() + ); + for a in &all_artifacts { + oplog::log_outcome("purge", &a.path.to_string_lossy(), "dry-run"); + } + return 0; + } + + if !opts.auto_yes { + println!("\nNon-interactive: pass --yes to confirm, or --dry-run to preview."); + println!("Aborted."); + return 0; + } + + // Non-dry-run execution: route strictly through trash::safe_delete + let mut deleted_count = 0; + let mut freed_bytes = 0; + for a in &all_artifacts { + match trash::safe_delete(&a.path, false) { + Ok(()) => { + oplog::log_outcome("purge", &a.path.to_string_lossy(), "success"); + deleted_count += 1; + freed_bytes += a.size; + } + Err(e) => { + oplog::log_outcome("purge", &a.path.to_string_lossy(), "failure"); + eprintln!(" warn: failed to trash {}: {}", a.path.display(), e); + } + } + } + + println!( + "\nAll done — purged {} artifact(s) (freed {})", + deleted_count, + size::human_size(freed_bytes as i64) + ); + 0 +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::{self, File}; + use std::path::PathBuf; + + fn tempdir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!( + "mu-test-purge-exec-{}-{}-{}", + tag, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn purge_dry_run_leaves_files_intact() { + let root = tempdir("dry"); + let nm = root.join("node_modules"); + fs::create_dir_all(&nm).unwrap(); + let sentinel = nm.join("sentinel.js"); + File::create(&sentinel).unwrap(); + + let opts = Options { + paths: vec![root.clone()], + dry_run: true, + auto_yes: false, + include_empty: false, + debug: false, + }; + + let code = run(&opts); + assert_eq!(code, 0); + assert!(sentinel.exists(), "dry run must leave sentinel intact"); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn purge_aborts_without_auto_yes() { + let root = tempdir("abort"); + let nm = root.join("target"); + fs::create_dir_all(&nm).unwrap(); + let sentinel = nm.join("output.bin"); + File::create(&sentinel).unwrap(); + + let opts = Options { + paths: vec![root.clone()], + dry_run: false, + auto_yes: false, + include_empty: false, + debug: false, + }; + + let code = run(&opts); + assert_eq!(code, 0); + assert!(sentinel.exists(), "aborted purge must not delete anything"); + + let _ = fs::remove_dir_all(&root); + } +} diff --git a/src/purge/model.rs b/src/purge/model.rs new file mode 100644 index 0000000..2fb6953 --- /dev/null +++ b/src/purge/model.rs @@ -0,0 +1,29 @@ +//! Data structures for project artifact purge. + +use std::path::PathBuf; + +/// A discovered project build artifact. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PurgeArtifact { + /// Absolute path to the artifact directory. + pub path: PathBuf, + /// Artifact kind / name (e.g. "node_modules", "target", ".venv"). + pub kind: String, + /// Size in bytes. + pub size: u64, +} + +/// Options for `mu purge`. +#[derive(Debug, Clone, Default)] +pub struct Options { + /// Optional specific search roots. If empty, default roots are used. + pub paths: Vec, + /// Preview actions without deleting anything. + pub dry_run: bool, + /// Automatically confirm deletion without prompting. + pub auto_yes: bool, + /// Include zero-byte / empty build artifact directories. + pub include_empty: bool, + /// Enable verbose debug logging. + pub debug: bool, +} diff --git a/src/purge/scanner.rs b/src/purge/scanner.rs new file mode 100644 index 0000000..c9f03b8 --- /dev/null +++ b/src/purge/scanner.rs @@ -0,0 +1,233 @@ +//! Filesystem scanner for project build directories. + +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::paths; +use crate::purge::model::PurgeArtifact; +use crate::size; +use crate::xdg; + +/// Known project build artifact directory names. +pub const BUILD_DIR_NAMES: &[&str] = &[ + "node_modules", + "target", + "dist", + "build", + ".venv", + "__pycache__", + ".pytest_cache", + ".tox", + ".gradle", + ".next", +]; + +/// Resolve scan roots. If `configured` is non-empty, use those. +/// Otherwise, check `$XDG_CONFIG_HOME/mu/purge_paths`. +/// If absent, fall back to standard project directories under `$HOME`. +pub fn resolve_scan_roots(configured: &[PathBuf]) -> Vec { + if !configured.is_empty() { + return configured + .iter() + .map(|p| { + if p.is_absolute() { + p.clone() + } else if let Ok(canon) = fs::canonicalize(p) { + canon + } else { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from("/")) + .join(p) + } + }) + .collect(); + } + + let config_file = xdg::config_home().join("mu").join("purge_paths"); + if let Ok(content) = fs::read_to_string(&config_file) { + let mut roots = Vec::new(); + for line in content.lines() { + let trimmed = line.trim(); + if !trimmed.is_empty() && !trimmed.starts_with('#') { + let p = PathBuf::from(trimmed); + if p.exists() { + roots.push(p); + } + } + } + if !roots.is_empty() { + return roots; + } + } + + let home = xdg::home_dir(); + let defaults = ["Projects", "Code", "src", "Development"]; + defaults + .iter() + .map(|d| home.join(d)) + .filter(|p| p.is_dir()) + .collect() +} + +/// Recursively scan a root directory for build artifacts. +pub fn scan_root(root: &Path, include_empty: bool) -> Vec { + let mut artifacts = Vec::new(); + + // Validate root boundary + if paths::validate_cleanup_root(root).is_err() { + return artifacts; + } + + walk_dir(root, root, include_empty, 0, &mut artifacts); + artifacts +} + +fn is_dir_empty(path: &Path) -> bool { + match fs::read_dir(path) { + Ok(mut it) => it.next().is_none(), + Err(_) => true, + } +} + +fn walk_dir( + root: &Path, + current: &Path, + include_empty: bool, + depth: usize, + artifacts: &mut Vec, +) { + if depth > 10 { + return; + } + + let entries = match fs::read_dir(current) { + Ok(e) => e, + Err(_) => return, + }; + + for entry in entries.flatten() { + let path = entry.path(); + let file_type = match entry.file_type() { + Ok(ft) => ft, + Err(_) => continue, + }; + + // Inviolable rule: never follow symlinks + if file_type.is_symlink() { + continue; + } + + if !file_type.is_dir() { + continue; + } + + let name = match path.file_name().and_then(|n| n.to_str()) { + Some(n) => n, + None => continue, + }; + + // Skip version control internals + if name == ".git" || name == ".hg" || name == ".svn" { + continue; + } + + if BUILD_DIR_NAMES.contains(&name) { + // Validate candidate safety boundary + if paths::validate_cleanup_candidate(root, &path).is_ok() { + let empty = is_dir_empty(&path); + if !empty || include_empty { + let (bytes, _) = size::dir_size(&path); + artifacts.push(PurgeArtifact { + path: path.clone(), + kind: name.to_string(), + size: bytes, + }); + } + } + continue; + } + + // Recurse into subdirectories + walk_dir(root, &path, include_empty, depth + 1, artifacts); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::File; + + fn tempdir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!( + "mu-test-purge-scan-{}-{}-{}", + tag, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn scan_finds_project_artifacts_and_skips_internal_recursion() { + let root = tempdir("scan"); + let proj = root.join("my-project"); + let node_modules = proj.join("node_modules"); + let nested_inside_nm = node_modules.join("target"); // Should not be reported separately + let venv = proj.join(".venv"); + + fs::create_dir_all(&nested_inside_nm).unwrap(); + fs::create_dir_all(&venv).unwrap(); + + File::create(node_modules.join("package.json")).unwrap(); + File::create(venv.join("pyvenv.cfg")).unwrap(); + + let artifacts = scan_root(&root, false); + assert_eq!(artifacts.len(), 2); + let kinds: Vec<&str> = artifacts.iter().map(|a| a.kind.as_str()).collect(); + assert!(kinds.contains(&"node_modules")); + assert!(kinds.contains(&".venv")); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn scan_respects_include_empty() { + let root = tempdir("empty"); + let proj = root.join("empty-project"); + let dist = proj.join("dist"); + fs::create_dir_all(&dist).unwrap(); + + let without_empty = scan_root(&root, false); + assert!(without_empty.is_empty()); + + let with_empty = scan_root(&root, true); + assert_eq!(with_empty.len(), 1); + assert_eq!(with_empty[0].kind, "dist"); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn scan_skips_symlinks() { + let root = tempdir("symlink"); + let real_dir = tempdir("target-real"); + fs::create_dir_all(real_dir.join("sub")).unwrap(); + File::create(real_dir.join("artifact.bin")).unwrap(); + + #[cfg(unix)] + { + let link = root.join("node_modules"); + std::os::unix::fs::symlink(&real_dir, &link).unwrap(); + + let artifacts = scan_root(&root, false); + assert!(artifacts.is_empty()); + } + + let _ = fs::remove_dir_all(&root); + let _ = fs::remove_dir_all(&real_dir); + } +} diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 002d431..b3736ff 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -169,6 +169,9 @@ fn help_lists_subcommands() { "status", "uninstall", "completion", + "history", + "installer", + "purge", ] { assert!(out.contains(cmd), "missing {cmd} in help"); } @@ -305,3 +308,134 @@ fn analyze_relative_path_dry_run_reports_absolute_target() { let _ = std::fs::remove_dir_all(&test_dir); } +/// `mu history --limit 5` -> renders tabular log of recent operations, exit 0. +#[test] +fn history_limit_tabular() { + let o = mu(&["history", "--limit", "5"]); + assert_eq!(o.status.code(), Some(0), "stderr: {}", stderr(&o)); + let out = stdout(&o); + // Either header or no operations message + assert!( + out.contains("TIMESTAMP") || out.contains("No recent operations recorded"), + "unexpected output: {out}" + ); +} + +/// `mu history --json` -> valid JSON array with fields timestamp, action, outcome, target. +#[test] +fn history_json_array() { + let o = mu(&["history", "--json"]); + assert_eq!(o.status.code(), Some(0), "stderr: {}", stderr(&o)); + let v: serde_json::Value = serde_json::from_str(&stdout(&o)).expect("history JSON parse"); + assert!(v.is_array(), "expected JSON array: {v}"); + if let Some(first) = v.as_array().and_then(|arr| arr.first()) { + for key in ["timestamp", "action", "outcome", "target"] { + assert!(first.get(key).is_some(), "missing {key} in entry: {first}"); + } + } +} + +/// `mu history --help` -> renders usage matching golden fixture, exit 0. +#[test] +fn history_help_output() { + let o = mu(&["history", "--help"]); + assert_eq!(o.status.code(), Some(0)); + assert_eq!(stdout(&o), include_str!("golden/help-history.txt")); +} + +/// `mu purge --dry-run` -> scans search paths and exits 0. +#[test] +fn purge_dry_run_scans() { + let o = mu(&["purge", "--dry-run"]); + assert_eq!(o.status.code(), Some(0), "stderr: {}", stderr(&o)); + let out = stdout(&o); + assert!( + out.contains("project build artifact") + || out.contains("No project build artifacts found") + || out.contains("No project directories found"), + "unexpected purge dry run output: {out}" + ); +} + +/// Sentinel immutability test (purge): +/// `mu purge --dry-run --paths=/tmp/mu-purge-test` leaves sentinel.js intact. +#[test] +fn purge_sentinel_immutability() { + let test_dir = std::env::temp_dir().join(format!("mu-purge-test-{}", std::process::id())); + let nm = test_dir.join("node_modules"); + std::fs::create_dir_all(&nm).unwrap(); + let sentinel = nm.join("sentinel.js"); + std::fs::write(&sentinel, b"console.log('sentinel');").unwrap(); + + let o = mu(&[ + "purge", + "--dry-run", + &format!("--paths={}", test_dir.display()), + ]); + assert_eq!(o.status.code(), Some(0), "stderr: {}", stderr(&o)); + assert!( + sentinel.exists(), + "purge --dry-run must leave sentinel.js intact" + ); + + let out = stdout(&o); + assert!( + out.contains("DRY RUN"), + "output should mention DRY RUN: {out}" + ); + assert!( + out.contains("node_modules"), + "output should list node_modules: {out}" + ); + + let _ = std::fs::remove_dir_all(&test_dir); +} + +/// Sentinel immutability test (installer): +/// `mu installer --dry-run --paths=/tmp/mu-installer-test` leaves dummy.deb intact. +#[test] +fn installer_sentinel_immutability() { + let test_dir = std::env::temp_dir().join(format!("mu-installer-test-{}", std::process::id())); + std::fs::create_dir_all(&test_dir).unwrap(); + let dummy = test_dir.join("dummy.deb"); + std::fs::write(&dummy, b"dummy deb package content").unwrap(); + + let o = mu(&[ + "installer", + "--dry-run", + &format!("--paths={}", test_dir.display()), + ]); + assert_eq!(o.status.code(), Some(0), "stderr: {}", stderr(&o)); + assert!( + dummy.exists(), + "installer --dry-run must leave dummy.deb intact" + ); + + let out = stdout(&o); + assert!( + out.contains("DRY RUN"), + "output should mention DRY RUN: {out}" + ); + assert!( + out.contains("dummy.deb"), + "output should list dummy.deb: {out}" + ); + + let _ = std::fs::remove_dir_all(&test_dir); +} + +/// `mu purge --help` -> renders usage matching golden fixture, exit 0. +#[test] +fn purge_help_output() { + let o = mu(&["purge", "--help"]); + assert_eq!(o.status.code(), Some(0)); + assert_eq!(stdout(&o), include_str!("golden/help-purge.txt")); +} + +/// `mu installer --help` -> renders usage matching golden fixture, exit 0. +#[test] +fn installer_help_output() { + let o = mu(&["installer", "--help"]); + assert_eq!(o.status.code(), Some(0)); + assert_eq!(stdout(&o), include_str!("golden/help-installer.txt")); +} diff --git a/tests/golden/help-history.exitcode b/tests/golden/help-history.exitcode new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/golden/help-history.exitcode @@ -0,0 +1 @@ +0 diff --git a/tests/golden/help-history.stderr.txt b/tests/golden/help-history.stderr.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/golden/help-history.txt b/tests/golden/help-history.txt new file mode 100644 index 0000000..a30ae9b --- /dev/null +++ b/tests/golden/help-history.txt @@ -0,0 +1,12 @@ +View recent operations and deletion log from operations.log. + +Usage: + mu history [flags] + +Flags: + -h, --help help for history + --json Output operations log as a JSON array + --limit int Number of recent operations to display (default 20) + +Global Flags: + --debug Enable verbose debug logging diff --git a/tests/golden/help-installer.exitcode b/tests/golden/help-installer.exitcode new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/golden/help-installer.exitcode @@ -0,0 +1 @@ +0 diff --git a/tests/golden/help-installer.stderr.txt b/tests/golden/help-installer.stderr.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/golden/help-installer.txt b/tests/golden/help-installer.txt new file mode 100644 index 0000000..cbc4b7e --- /dev/null +++ b/tests/golden/help-installer.txt @@ -0,0 +1,13 @@ +Find and remove downloaded installer packages (.deb, .AppImage, .iso). + +Usage: + mu installer [flags] + +Flags: + --dry-run Preview actions without making changes + -h, --help help for installer + --paths strings Specific directories to scan + -y, --yes Skip confirmation prompt + +Global Flags: + --debug Enable verbose debug logging diff --git a/tests/golden/help-purge.exitcode b/tests/golden/help-purge.exitcode new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/golden/help-purge.exitcode @@ -0,0 +1 @@ +0 diff --git a/tests/golden/help-purge.stderr.txt b/tests/golden/help-purge.stderr.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/golden/help-purge.txt b/tests/golden/help-purge.txt new file mode 100644 index 0000000..4dbaa9a --- /dev/null +++ b/tests/golden/help-purge.txt @@ -0,0 +1,14 @@ +Safely clean heavy project build artifacts (node_modules, target, .venv). + +Usage: + mu purge [flags] + +Flags: + --dry-run Preview actions without making changes + -h, --help help for purge + --include-empty Include empty build artifact directories + --paths strings Specific project directories to scan + -y, --yes Skip confirmation prompt + +Global Flags: + --debug Enable verbose debug logging diff --git a/tests/golden/help.txt b/tests/golden/help.txt index 02d1913..4d49c05 100644 --- a/tests/golden/help.txt +++ b/tests/golden/help.txt @@ -13,7 +13,10 @@ Available Commands: clean Free disk space by removing caches, logs, and old packages completion Generate the autocompletion script for the specified shell help Help about any command + history View recent operations and deletion log + installer Find and remove downloaded installer packages (.deb, .AppImage, .iso) optimize Run system maintenance: apt autoremove, journal vacuum, cache refresh + purge Safely clean heavy project build artifacts (node_modules, target, .venv) status Live system dashboard: CPU, RAM, disk, network, health score uninstall Remove apps and all their config, cache, and data remnants From 6dcf8ae0c5fe4dcdaf1f5ff188f122323cb5aa89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ba=20R=E1=BB=8Di?= Date: Wed, 14 Oct 2026 17:10:38 +0700 Subject: [PATCH 05/17] feat(clean-uninstall): implement Phase 2 core clean and uninstall parity (MU-003) --- src/clean/mod.rs | 148 ++++++++++++++++++++++-- src/clean/scan_browser.rs | 195 ++++++++++++++++++++++++++++---- src/clean/targets.rs | 50 ++++++++ src/cli.rs | 49 ++++++-- src/tui/mod.rs | 1 + src/tui/uninstall.rs | 128 ++++++++++++++++++++- src/uninstall/discover.rs | 101 ++++++++++++++++- src/uninstall/mod.rs | 161 +++++++++++++++++++++++++- src/uninstall/remnants.rs | 34 +++++- src/uninstall/remove.rs | 85 +++++++++++++- tests/cli_integration.rs | 107 ++++++++++++++++++ tests/golden/help-uninstall.txt | 8 +- 12 files changed, 1011 insertions(+), 56 deletions(-) diff --git a/src/clean/mod.rs b/src/clean/mod.rs index b4ab35c..56e9588 100644 --- a/src/clean/mod.rs +++ b/src/clean/mod.rs @@ -138,6 +138,7 @@ pub(crate) fn all_targets_in(deps: &Deps) -> Vec { scan_snap::snap_target_in(deps), scan_kernels::kernels_target_in(deps), scan_browser::browser_cache_target_in(deps), + targets::system_trash_target_in(deps), ]; if let Some(docker) = scan_docker::docker_target_in(deps) { targets.push(docker); @@ -298,15 +299,17 @@ fn run_plain( }); for res in &results { - // `r.Line("%-40s %s", ...)`. - r.line(format_args!( - "{:<40} {}", - res.target.label, - size::human_size(res.size) - ))?; + let label = if res.target.id == "system-trash" { + let count = targets::count_trash_items(&deps.data_home); + format!( + "System Trash ({count} item{})", + if count == 1 { "" } else { "s" } + ) + } else { + res.target.label.to_string() + }; + r.line(format_args!("{:<40} {}", label, size::human_size(res.size)))?; for item in &res.items { - // `r.Line(" - %s", item)` — the renderer's 2-space indent plus - // the format's own 2 spaces. r.line(format_args!(" - {item}"))?; } } @@ -315,6 +318,7 @@ fn run_plain( "Potential space to free: {}", size::human_size(total) ))?; + export_preview_ledger(&results, total, &deps.config_home, &deps.data_home); if !scan_errors.is_empty() { return Err(join_errors(&scan_errors)); } @@ -325,6 +329,7 @@ fn run_plain( "Validating the exact cleanup actions without changing data." ))?; execute(&mut r, &results, opts, deps)?; + let _ = print_downloads_large_files_hint(&deps.home, &mut r); let summary = format!("Dry run — {} reclaimable", size::human_size(total)); r.summary(&summary)?; return Ok(summary); @@ -339,10 +344,77 @@ fn run_plain( } let freed = execute(&mut r, &results, opts, deps)?; + let _ = print_downloads_large_files_hint(&deps.home, &mut r); r.summary(&format!("All done — freed {}", size::human_size(freed)))?; Ok(format!("Freed {}", size::human_size(freed))) } +fn export_preview_ledger( + results: &[ScanResult], + total: i64, + config_home: &std::path::Path, + data_home: &std::path::Path, +) { + let mu_dir = config_home.join("mu"); + let _ = std::fs::create_dir_all(&mu_dir); + let ledger_path = mu_dir.join("clean-list.txt"); + let mut f = match std::fs::File::create(&ledger_path) { + Ok(f) => f, + Err(_) => return, + }; + let _ = writeln!(f, "# mu clean preview ledger"); + let _ = writeln!( + f, + "# Total potential space to free: {}", + size::human_size(total) + ); + let _ = writeln!(f, "--------------------------------------------------"); + for res in results { + let label = if res.target.id == "system-trash" { + let count = targets::count_trash_items(data_home); + format!( + "System Trash ({count} item{})", + if count == 1 { "" } else { "s" } + ) + } else { + res.target.label.to_string() + }; + let _ = writeln!(f, "{:<40} {}", label, size::human_size(res.size)); + for item in &res.items { + let _ = writeln!(f, " - {item}"); + } + } +} + +fn print_downloads_large_files_hint( + home: &std::path::Path, + r: &mut runtext::Run<'_>, +) -> Result<()> { + let downloads = home.join("Downloads"); + let Ok(entries) = std::fs::read_dir(&downloads) else { + return Ok(()); + }; + let mut count = 0; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(meta) = path.symlink_metadata() else { + continue; + }; + if meta.file_type().is_symlink() || !meta.is_file() { + continue; + } + if meta.len() > 500 * 1024 * 1024 { + count += 1; + } + } + if count > 0 { + r.line(format_args!( + "\nHint: found {count} large file{} (>500MB) in ~/Downloads (run 'mu analyze' to inspect)", + if count == 1 { "" } else { "s" } + ))?; + } + Ok(()) +} /// `execute` — run each target's `Execute` in order, logging per-target /// outcomes and warning on stderr for failures. Returns the summed size of /// the successful targets; one or more failures yield @@ -743,4 +815,64 @@ mod tests { assert!(paths::path_exists(&thumbs), "dry-run removed thumbnails"); fs::remove_dir_all(&tmp).ok(); } + + #[test] + fn system_trash_target_and_ledger_test() { + let tmp = tempdir("systrash"); + let fake_runner = Arc::new(FakeRunner::new()); + fake_runner.set_look_path("gio", "/usr/bin/gio"); + let deps = deps_for_test(&tmp, fake_runner); + let trash_files = deps.data_home.join("Trash").join("files"); + fs::create_dir_all(&trash_files).unwrap(); + fs::write(trash_files.join("item1.txt"), "hello").unwrap(); + fs::write(trash_files.join("item2.txt"), "world!").unwrap(); + + let trash_target = targets::system_trash_target_in(&deps); + assert!(trash_target.opt_in, "system-trash should be opt-in"); + assert_eq!(trash_target.id, "system-trash"); + + let (sz, err) = (trash_target.scan)(); + assert!(err.is_none()); + assert!(sz > 0); + assert_eq!(targets::count_trash_items(&deps.data_home), 2); + + // Execute in dry-run mode + (trash_target.execute)(true).expect("dry-run should succeed"); + assert!(trash_files.join("item1.txt").exists()); + + // Test export_preview_ledger + let res = ScanResult { + target: trash_target, + size: sz, + items: vec!["item1.txt".into(), "item2.txt".into()], + }; + export_preview_ledger(&[res], sz, &deps.config_home, &deps.data_home); + let ledger = tmp.join(".config").join("mu").join("clean-list.txt"); + assert!(ledger.exists(), "clean-list.txt should be created"); + let content = fs::read_to_string(&ledger).unwrap(); + assert!(content.contains("System Trash (2 items)")); + + fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn downloads_large_files_hint_test() { + let tmp = tempdir("dlhint"); + let downloads = tmp.join("Downloads"); + fs::create_dir_all(&downloads).unwrap(); + let big_file = downloads.join("large_iso.img"); + // Allocate sparse or small file? We can check with mock or small if size is tested + // But wait: metadata.len() needs to be > 500MB. + // With set_len: + let f = fs::File::create(&big_file).unwrap(); + f.set_len(501 * 1024 * 1024).unwrap(); // sparse file, takes 0 actual disk space + + let mut out = Vec::new(); + let mut r = runtext::Run::new(&mut out); + print_downloads_large_files_hint(&tmp, &mut r).unwrap(); + let s = String::from_utf8_lossy(&out); + assert!(s.contains("Hint: found 1 large file (>500MB) in ~/Downloads")); + + fs::remove_dir_all(&tmp).ok(); + } } diff --git a/src/clean/scan_browser.rs b/src/clean/scan_browser.rs index 5c85b2d..6a97a04 100644 --- a/src/clean/scan_browser.rs +++ b/src/clean/scan_browser.rs @@ -10,22 +10,26 @@ use super::{CleanTarget, Deps, join_errors}; /// `browserCachePaths` — all browser cache paths that exist on the system. /// `home` is `os.UserHomeDir()`. -pub(crate) fn browser_cache_paths_in(home: &Path) -> Vec { - let mut candidates = vec![ - home.join(".config") - .join("google-chrome") - .join("Default") - .join("Cache"), - home.join(".config").join("Code").join("CachedData"), - home.join(".config").join("Code").join("CachedExtensions"), - ]; +/// Protected productivity domains whose ServiceWorker and IndexedDB storage +/// must not be removed to prevent losing offline PWA state. +const PROTECTED_PWA_DOMAINS: &[&str] = &["google", "docs.google.com", "github", "figma", "linear"]; - // Firefox: ~/.mozilla/firefox/*/cache2 (may match multiple profiles). - // `filepath.Glob` semantics: each `*` candidate must stat as a directory - // (symlinks followed) and contain a child literally named `cache2`; - // missing/unreadable levels yield no matches rather than an error. - let firefox = home.join(".mozilla").join("firefox"); - if let Ok(entries) = std::fs::read_dir(&firefox) { +/// Returns true if the path points to ServiceWorker or IndexedDB data belonging +/// to a protected productivity domain. +pub(crate) fn is_protected_pwa_path(path: &Path) -> bool { + let s = path.to_string_lossy().to_lowercase(); + let is_pwa_storage = + s.contains("service worker") || s.contains("serviceworker") || s.contains("indexeddb"); + if !is_pwa_storage { + return false; + } + PROTECTED_PWA_DOMAINS.iter().any(|d| s.contains(d)) +} + +/// Helper to find Firefox-like `*/cache2` profile directories under a root. +fn find_firefox_cache2_paths(parent: &Path) -> Vec { + let mut found = Vec::new(); + if let Ok(entries) = std::fs::read_dir(parent) { for e in entries.flatten() { let profile = e.path(); let Ok(meta) = std::fs::metadata(&profile) else { @@ -39,14 +43,107 @@ pub(crate) fn browser_cache_paths_in(home: &Path) -> Vec { .flatten() .any(|ie| ie.file_name() == std::ffi::OsStr::new("cache2")) { - candidates.push(profile.join("cache2")); + found.push(profile.join("cache2")); + } + } + } + found +} + +/// `browserCachePaths` — all browser cache paths that exist on the system. +/// `home` is `os.UserHomeDir()`. +pub(crate) fn browser_cache_paths_in(home: &Path) -> Vec { + let mut candidates = vec![ + // Google Chrome + home.join(".config") + .join("google-chrome") + .join("Default") + .join("Cache"), + // Chromium + home.join(".config") + .join("chromium") + .join("Default") + .join("Cache"), + // VSCode + home.join(".config").join("Code").join("CachedData"), + home.join(".config").join("Code").join("CachedExtensions"), + // Brave + home.join(".config") + .join("BraveSoftware") + .join("Brave-Browser") + .join("Default") + .join("Cache"), + // Microsoft Edge + home.join(".config") + .join("microsoft-edge") + .join("Default") + .join("Cache"), + // Opera + home.join(".config").join("opera").join("Cache"), + // Vivaldi + home.join(".config") + .join("vivaldi") + .join("Default") + .join("Cache"), + // Snap Chromium + home.join("snap") + .join("chromium") + .join("common") + .join(".cache") + .join("chromium") + .join("Default") + .join("Cache"), + ]; + + // Firefox native: ~/.mozilla/firefox/*/cache2 + candidates.extend(find_firefox_cache2_paths( + &home.join(".mozilla").join("firefox"), + )); + + // Zen Browser: ~/.zen/*/cache2 + candidates.extend(find_firefox_cache2_paths(&home.join(".zen"))); + + // Snap Firefox: ~/snap/firefox/common/.cache/mozilla/firefox/*/cache2 + candidates.extend(find_firefox_cache2_paths( + &home + .join("snap") + .join("firefox") + .join("common") + .join(".cache") + .join("mozilla") + .join("firefox"), + )); + + // Flatpak browsers under ~/.var/app/*/cache + let var_app = home.join(".var").join("app"); + if let Ok(entries) = std::fs::read_dir(&var_app) { + for e in entries.flatten() { + let app_dir = e.path(); + let name = e.file_name(); + let name_str = name.to_string_lossy().to_lowercase(); + if name_str.contains("firefox") + || name_str.contains("chrome") + || name_str.contains("brave") + || name_str.contains("chromium") + || name_str.contains("zen") + { + let cache_dir = app_dir.join("cache"); + if paths::path_exists(&cache_dir) { + // Check for inner cache2 if firefox-based + let ff_inner = find_firefox_cache2_paths(&cache_dir); + if !ff_inner.is_empty() { + candidates.extend(ff_inner); + } else { + candidates.push(cache_dir); + } + } } } } candidates .into_iter() - .filter(|p| paths::path_exists(p)) + .filter(|p| paths::path_exists(p) && !is_protected_pwa_path(p)) .collect() } @@ -60,7 +157,7 @@ pub(crate) fn browser_cache_target_in(deps: &Deps) -> CleanTarget { CleanTarget { id: "browser-cache", - label: "Browser Caches (Chrome/Firefox/VSCode)", + label: "Browser Caches (Chrome/Firefox/VSCode/Snap/Flatpak)", requires_sudo: false, opt_in: true, scan: Box::new(move || { @@ -73,8 +170,6 @@ pub(crate) fn browser_cache_target_in(deps: &Deps) -> CleanTarget { Some(e) => scan_errors.push(e), } } - // Go: `return total, errors.Join(scanErrors...)` — partial - // total rides back with the error. if scan_errors.is_empty() { (total, None) } else { @@ -85,6 +180,9 @@ pub(crate) fn browser_cache_target_in(deps: &Deps) -> CleanTarget { execute: Box::new(move |dry_run| { let mut delete_errors: Vec = Vec::new(); for p in browser_cache_paths_in(&exec_home) { + if is_protected_pwa_path(&p) { + continue; + } let root = browser_cleanup_root(&exec_home, &p); if let Err(e) = paths::validate_cleanup_candidate(&root, &p) { delete_errors.push(e); @@ -105,12 +203,21 @@ pub(crate) fn browser_cache_target_in(deps: &Deps) -> CleanTarget { } } -/// `browserCleanupRoot` — paths under `~/.mozilla` validate against the -/// `.mozilla` root; everything else against `~/.config`. +/// `browserCleanupRoot` — paths under `~/.mozilla`, `~/snap`, `~/.var`, `~/.zen` +/// validate against their respective roots; everything else against `~/.config`. pub(crate) fn browser_cleanup_root(home: &Path, path: &Path) -> PathBuf { let mozilla = home.join(".mozilla"); + let snap = home.join("snap"); + let var = home.join(".var"); + let zen = home.join(".zen"); if path == mozilla || path.starts_with(&mozilla) { mozilla + } else if path == snap || path.starts_with(&snap) { + snap + } else if path == var || path.starts_with(&var) { + var + } else if path == zen || path.starts_with(&zen) { + zen } else { home.join(".config") } @@ -194,4 +301,48 @@ mod tests { ); fs::remove_dir_all(&tmp).ok(); } + + #[test] + fn test_is_protected_pwa_path() { + assert!(is_protected_pwa_path(Path::new( + "/home/user/.config/google-chrome/Default/Service Worker/CacheStorage/docs.google.com" + ))); + assert!(is_protected_pwa_path(Path::new( + "/home/user/.config/google-chrome/Default/IndexedDB/https_github.com_0.indexeddb.leveldb" + ))); + assert!(is_protected_pwa_path(Path::new( + "/home/user/.config/BraveSoftware/Brave-Browser/Default/ServiceWorker/figma" + ))); + assert!(!is_protected_pwa_path(Path::new( + "/home/user/.config/google-chrome/Default/Cache" + ))); + assert!(!is_protected_pwa_path(Path::new( + "/home/user/.mozilla/firefox/abc/cache2" + ))); + } + + #[test] + fn test_browser_cleanup_root_expanded() { + let home = Path::new("/home/user"); + assert_eq!( + browser_cleanup_root(home, &home.join("snap/firefox/common/.cache")), + home.join("snap") + ); + assert_eq!( + browser_cleanup_root(home, &home.join(".var/app/org.mozilla.firefox/cache")), + home.join(".var") + ); + assert_eq!( + browser_cleanup_root(home, &home.join(".zen/profile.default/cache2")), + home.join(".zen") + ); + assert_eq!( + browser_cleanup_root(home, &home.join(".mozilla/firefox/profile.default/cache2")), + home.join(".mozilla") + ); + assert_eq!( + browser_cleanup_root(home, &home.join(".config/google-chrome/Default/Cache")), + home.join(".config") + ); + } } diff --git a/src/clean/targets.rs b/src/clean/targets.rs index 60f67a1..256ace6 100644 --- a/src/clean/targets.rs +++ b/src/clean/targets.rs @@ -500,6 +500,56 @@ pub(crate) fn sandbox_font_cache_dirs_in(home: &Path) -> Vec<(PathBuf, PathBuf)> found } +/// `count_trash_items` — counts entries in `$XDG_DATA_HOME/Trash/files`. +pub(crate) fn count_trash_items(data_home: &Path) -> usize { + let files_dir = data_home.join("Trash").join("files"); + let Ok(entries) = std::fs::read_dir(&files_dir) else { + return 0; + }; + entries.flatten().count() +} + +/// `system_trash_size` — total bytes in `$XDG_DATA_HOME/Trash`. +pub(crate) fn system_trash_size(data_home: &Path) -> i64 { + let trash_dir = data_home.join("Trash"); + if !trash_dir.exists() { + return 0; + } + let (sz, _) = size::dir_size(&trash_dir); + sz as i64 +} + +/// `systemTrashTarget` — CleanTarget for system trash (opt-in). +/// Previews item count and size; executes strictly via `gio trash --empty`. +pub(crate) fn system_trash_target_in(deps: &Deps) -> CleanTarget { + let scan_data = deps.data_home.clone(); + let exec_runner = Arc::clone(&deps.runner); + + CleanTarget { + id: "system-trash", + label: "System Trash", + requires_sudo: false, + opt_in: true, + scan: Box::new(move || { + let sz = system_trash_size(&scan_data); + (sz, None) + }), + preview: None, + execute: Box::new(move |dry_run| { + if dry_run { + return Ok(()); + } + if exec_runner.look_path("gio").is_ok() { + exec_runner + .run(&CommandSpec::new("gio", ["trash", "--empty"])) + .map(|_| ()) + .map_err(|e| Error::Msg(format!("empty trash: {e}")))?; + } + Ok(()) + }), + } +} + /// `JournalSize` — parses `journalctl --disk-usage` to get journal size in /// bytes. A missing journalctl is optional; command and parse failures are /// returned. diff --git a/src/cli.rs b/src/cli.rs index e130f4d..eecb319 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -193,11 +193,13 @@ Executes: apt purge + remnant cleanup. Use --dry-run to preview without removing anything. Usage: - mu uninstall [flags] + mu uninstall [flags] [package...] Flags: - --dry-run Preview actions without making changes - -h, --help help for uninstall + --dry-run Preview actions without making changes + -h, --help help for uninstall + --list List installed packages and exit + --permanent Bypass trash (rejected for safety) Global Flags: --debug Enable verbose debug logging @@ -447,6 +449,9 @@ enum Command { }, Uninstall { dry_run: bool, + list: bool, + permanent: bool, + packages: Vec, }, } @@ -553,9 +558,18 @@ pub fn run() -> ! { debug, include, })), - Command::Uninstall { dry_run } => { - std::process::exit(uninstall::run(&uninstall::Options { dry_run, debug })) - } + Command::Uninstall { + dry_run, + list, + permanent, + packages, + } => std::process::exit(uninstall::run(&uninstall::Options { + dry_run, + debug, + list, + permanent, + packages, + })), } } } @@ -676,6 +690,18 @@ const F_NODESC: FlagSpec = FlagSpec { takes_value: false, display: "--no-descriptions", }; +const F_PERMANENT: FlagSpec = FlagSpec { + long: "permanent", + short: None, + takes_value: false, + display: "--permanent", +}; +const F_LIST: FlagSpec = FlagSpec { + long: "list", + short: None, + takes_value: false, + display: "--list", +}; const ROOT_FLAGS: &[FlagSpec] = &[F_DEBUG, F_HELP, F_VERSION]; const HELP_FLAGS: &[FlagSpec] = &[F_DEBUG, F_HELP]; @@ -689,7 +715,7 @@ fn cmd_flagset(name: &str) -> &'static [FlagSpec] { "clean" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_YES, F_INCLUDE], "optimize" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_YES, F_SKIP], "audit" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_JSON, F_REPORT, F_INCLUDE], - "uninstall" => &[F_DEBUG, F_HELP, F_DRY_RUN], + "uninstall" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_PERMANENT, F_LIST], _ => &[], } } @@ -1003,6 +1029,7 @@ fn exec_subcommand(name: &str, args: &[String]) -> Outcome { if p.get("help") { return Outcome::Stdout(cmd_help(name).unwrap().to_string()); } + let debug = p.get("debug"); let cmd = match name { "analyze" => Command::Analyze { path: p.positionals.first().map(std::path::PathBuf::from), @@ -1030,13 +1057,13 @@ fn exec_subcommand(name: &str, args: &[String]) -> Outcome { }, "uninstall" => Command::Uninstall { dry_run: p.get("dry-run"), + list: p.get("list"), + permanent: p.get("permanent"), + packages: p.positionals, }, _ => unreachable!(), }; - Outcome::Run { - debug: p.get("debug"), - cmd, - } + Outcome::Run { debug, cmd } } /// `mu help [topics]` — cobra's help command. Flags parse first (so diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 2d5482c..640eaaf 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -446,6 +446,7 @@ fn dispatch_subcommand(command: &str, debug: bool) -> anyhow::Result, + pub remnant_cursor: usize, } - /// Package item — mirrors Go's `pkgItem`. #[derive(Clone, Debug)] pub struct PkgItem { @@ -77,6 +79,8 @@ impl UninstallTui { window_h: 24, scroll_off: 0, confirm_cursor: 1, // default to NO + remnant_items: Vec::new(), + remnant_cursor: 0, } } @@ -210,6 +214,25 @@ impl UninstallTui { match key { // Go: only q and ctrl+c quit the confirm (not Esc). KeyCode::Char('q') => return true, + KeyCode::Char('r') => { + self.remnant_items.clear(); + for (key, sel) in &self.selected { + if !sel { + continue; + } + for it in &self.all_items { + if it.pkg.key() == *key { + for r in &it.pkg.remnants_found { + self.remnant_items.push((key.clone(), r.clone(), true)); + } + } + } + } + if !self.remnant_items.is_empty() { + self.remnant_cursor = 0; + self.phase = Phase::ReviewRemnants; + } + } KeyCode::Left | KeyCode::Char('h') | KeyCode::Right @@ -228,6 +251,40 @@ impl UninstallTui { _ => {} } } + Phase::ReviewRemnants => { + match key { + KeyCode::Up => { + if self.remnant_cursor > 0 { + self.remnant_cursor -= 1; + } + } + KeyCode::Down => { + if self.remnant_cursor + 1 < self.remnant_items.len() { + self.remnant_cursor += 1; + } + } + KeyCode::Char(' ') => { + if self.remnant_cursor < self.remnant_items.len() { + self.remnant_items[self.remnant_cursor].2 = + !self.remnant_items[self.remnant_cursor].2; + } + } + KeyCode::Enter | KeyCode::Esc => { + // Apply remnant selections back to all_items + for (pkg_key, path, keep) in &self.remnant_items { + if !keep { + for it in &mut self.all_items { + if it.pkg.key() == *pkg_key { + it.pkg.remnants_found.retain(|r| r != path); + } + } + } + } + self.phase = Phase::Confirm; + } + _ => {} + } + } Phase::Done => {} } false @@ -398,7 +455,36 @@ impl UninstallTui { lines.push(Line::raw("")); lines.push(Line::raw("")); lines.push(Line::raw("")); - lines.push(Line::raw(" ←/→ navigate • Enter: confirm • q: quit")); + lines.push(Line::raw( + " ←/→: toggle • r: review remnants • Enter: confirm • q: quit", + )); + } + Phase::ReviewRemnants => { + lines.push(Line::raw("")); + lines.push(Line::styled( + " Review Remnants (Space to toggle, Enter to save):", + styles::bold_primary(), + )); + lines.push(Line::raw("")); + for (idx, (pkg_key, path, sel)) in self.remnant_items.iter().enumerate() { + let check = if *sel { "[x]" } else { "[ ]" }; + let text = format!(" {check} {path} ({pkg_key})"); + if idx == self.remnant_cursor { + lines.push(Line::styled( + text, + ratatui::style::Style::default() + .bg(ratatui::style::Color::Rgb(0x1F, 0x29, 0x37)) + .fg(ratatui::style::Color::Rgb(0xF9, 0xFA, 0xFB)), + )); + } else { + lines.push(Line::raw(text)); + } + } + lines.push(Line::raw("")); + lines.push(Line::styled( + " ↑/↓: navigate • Space: toggle • Enter/Esc: back to confirm", + styles::faint(), + )); } Phase::Done => { lines.push(Line::raw("")); @@ -613,4 +699,42 @@ mod tests { assert!(content.contains("Will remove:")); assert!(content.contains("vim")); } + + #[test] + fn review_remnants_checklist_toggles_and_saves() { + let mut tui = UninstallTui::new(); + tui.phase = Phase::Confirm; + tui.selected.insert("apt:vim".into(), true); + tui.all_items = vec![PkgItem { + pkg: Package { + name: "vim".into(), + source: "apt".into(), + remnants_found: vec![ + "/home/user/.config/vim".into(), + "/home/user/.cache/vim".into(), + ], + ..Default::default() + }, + selected: true, + }]; + + // Press 'r' to enter remnant review + tui.handle_key(KeyCode::Char('r')); + assert_eq!(tui.phase, Phase::ReviewRemnants); + assert_eq!(tui.remnant_items.len(), 2); + assert!(tui.remnant_items[0].2); // initially selected + + // Toggle first remnant off with Space + tui.handle_key(KeyCode::Char(' ')); + assert!(!tui.remnant_items[0].2); + + // Press Enter to save and return to Confirm + tui.handle_key(KeyCode::Enter); + assert_eq!(tui.phase, Phase::Confirm); + // The first remnant should have been deselected and removed from remnants_found + assert_eq!( + tui.all_items[0].pkg.remnants_found, + vec!["/home/user/.cache/vim"] + ); + } } diff --git a/src/uninstall/discover.rs b/src/uninstall/discover.rs index d25c4b6..60f8681 100644 --- a/src/uninstall/discover.rs +++ b/src/uninstall/discover.rs @@ -141,6 +141,84 @@ fn scan_i64(s: &str) -> i64 { if neg { -v } else { v } } +/// `DiscoverFlatpak` — installed Flatpak application packages. +pub fn discover_flatpak(runner: &dyn Runner) -> Result> { + if runner.look_path("flatpak").is_err() { + return Ok(Vec::new()); + } + let out = runner + .run(&CommandSpec::new( + "flatpak", + ["list", "--app", "--columns=application,version,size,origin"], + )) + .map_err(|e| Error::Msg(format!("flatpak list: {e}")))?; + Ok(parse_flatpak_output(&out.stdout_lossy())) +} + +/// `parseFlatpakOutput` — parses rows of `flatpak list --app`. +pub(crate) fn parse_flatpak_output(output: &str) -> Vec { + let mut pkgs = Vec::new(); + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let fields: Vec<&str> = line.split('\t').collect(); + let fields: Vec<&str> = if fields.len() >= 2 { + fields + } else { + line.split_whitespace().collect() + }; + if fields.is_empty() { + continue; + } + let name = fields[0].trim().to_string(); + let version = fields + .get(1) + .map(|v| v.trim().to_string()) + .unwrap_or_default(); + let size_str = fields.get(2).map(|s| s.trim()).unwrap_or(""); + let installed_kb = parse_size_to_kb(size_str); + pkgs.push(Package { + name, + version, + source: "flatpak".to_string(), + installed_kb, + ..Default::default() + }); + } + pkgs +} + +fn parse_size_to_kb(s: &str) -> i64 { + let s = s.trim(); + if s.is_empty() { + return 0; + } + let mut num_str = String::new(); + let mut unit_str = String::new(); + for c in s.chars() { + if c.is_ascii_digit() || c == '.' { + num_str.push(c); + } else if !c.is_whitespace() { + unit_str.push(c); + } + } + let val: f64 = num_str.parse().unwrap_or(0.0); + let unit = unit_str.to_uppercase(); + if unit.starts_with('G') { + (val * 1024.0 * 1024.0) as i64 + } else if unit.starts_with('M') { + (val * 1024.0) as i64 + } else if unit.starts_with('K') { + val as i64 + } else if unit.starts_with('B') { + (val / 1024.0) as i64 + } else { + val as i64 + } +} + /// `Discover` — all installed packages (APT + Snap) sorted by name, plus the /// joined per-source errors. Partial results are returned alongside the error /// exactly like Go's `(all, errors.Join(aptErr, snapErr))` pair. Rust's sort @@ -155,11 +233,16 @@ pub fn discover(runner: &dyn Runner) -> (Vec, Option) { Ok(pkgs) => (pkgs, None), Err(e) => (Vec::new(), Some(e)), }; + let (flatpak, flatpak_err) = match discover_flatpak(runner) { + Ok(pkgs) => (pkgs, None), + Err(e) => (Vec::new(), Some(e)), + }; let mut all = apt; all.extend(snap); + all.extend(flatpak); all.sort_by(|a, b| a.name.cmp(&b.name)); - // `errors.Join(apt discovery: %w, snap discovery: %w)` — newline-joined. + // `errors.Join(apt discovery: %w, snap discovery: %w, flatpak discovery: %w)` let mut errs: Vec = Vec::new(); if let Some(e) = apt_err { errs.push(format!("apt discovery: {e}")); @@ -167,6 +250,9 @@ pub fn discover(runner: &dyn Runner) -> (Vec, Option) { if let Some(e) = snap_err { errs.push(format!("snap discovery: {e}")); } + if let Some(e) = flatpak_err { + errs.push(format!("flatpak discovery: {e}")); + } let err = if errs.is_empty() { None } else { @@ -217,6 +303,19 @@ mod tests { assert_eq!(pkgs[0].source, "snap"); } + #[test] + fn parse_flatpak_output_parses_rows() { + let fixture = "org.mozilla.firefox\t120.0\t250 MB\tflathub\n\ + com.spotify.Client\t1.2.0\t1.5 GB\tflathub\n"; + let pkgs = parse_flatpak_output(fixture); + assert_eq!(pkgs.len(), 2); + assert_eq!(pkgs[0].name, "org.mozilla.firefox"); + assert_eq!(pkgs[0].source, "flatpak"); + assert_eq!(pkgs[0].installed_kb, 250 * 1024); + assert_eq!(pkgs[1].name, "com.spotify.Client"); + assert_eq!(pkgs[1].installed_kb, (1.5 * 1024.0 * 1024.0) as i64); + } + // discover_test.go: TestDiscoverReturnsPartialPackagesAndJoinedErrors #[test] fn discover_returns_partial_packages_and_joined_errors() { diff --git a/src/uninstall/mod.rs b/src/uninstall/mod.rs index 04b8061..080667d 100644 --- a/src/uninstall/mod.rs +++ b/src/uninstall/mod.rs @@ -42,12 +42,18 @@ pub(crate) use model::{Phase, UninstallModel}; pub(crate) use remove::remove_selected_in; /// `uninstall.Options` — controls uninstall behavior. -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Debug, Default)] pub struct Options { /// `DryRun` — preview actions without making changes. pub dry_run: bool, /// `Debug` — verbose logging (warn when the ops log cannot open). pub debug: bool, + /// `List` — list installed packages and exit. + pub list: bool, + /// `Permanent` — bypass trash (rejected for safety). + pub permanent: bool, + /// `Packages` — direct CLI package names to remove. + pub packages: Vec, } /// Dependency bundle replacing the Go package vars. `runner` is @@ -153,7 +159,7 @@ fn finish_with_items( out: &mut dyn Write, err_out: &mut dyn Write, ) -> i32 { - let mut model = crate::uninstall::model::new_model(*opts); + let mut model = crate::uninstall::model::new_model(opts.clone()); model.phase = Phase::Done; for it in items.iter().filter(|it| it.selected) { model.selected.insert(it.pkg.key(), true); @@ -181,6 +187,39 @@ pub(crate) fn run_in(opts: &Options, deps: &Deps, err_out: &mut dyn Write) -> i3 } let _log = CloseLoggerGuard; + // Reject --permanent with exit code 1 and explanatory safety error + if opts.permanent { + let _ = writeln!( + err_out, + "error: --permanent is rejected: mu enforces SafeDelete via trash only to guarantee recoverability. To free space after uninstalling, review and empty trash via 'gio trash --empty' or 'mu clean --include=system-trash'." + ); + return 1; + } + + // Handle --list + if opts.list { + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + let (pkgs, err) = discover(&*deps.runner); + if let Some(e) = err + && opts.debug + { + let _ = writeln!(err_out, "warn: discovery errors: {e}"); + } + for pkg in pkgs { + let sz = crate::size::human_kb(pkg.installed_kb as u64); + let _ = writeln!(out, "{}:{} ({})", pkg.source, pkg.name, sz); + } + return 0; + } + + // Handle direct CLI package uninstall + if !opts.packages.is_empty() { + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + return run_cli_uninstall(opts, deps, &mut out, err_out); + } + // Go: `tea.NewProgram(m, tea.WithAltScreen()).Run()` is the only path. // A headless run fails inside tea's program init with // `could not open a new TTY: open /dev/tty: no such device or address`, @@ -192,7 +231,6 @@ pub(crate) fn run_in(opts: &Options, deps: &Deps, err_out: &mut dyn Write) -> i3 ); return 1; } - // TTY: the interactive TUI returns its full item list with selected // flags (None when they quit/abort — Go's `final.phase != phaseDone` → // nil → exit 0). @@ -246,6 +284,70 @@ pub(crate) fn finish( let _ = writeln!(out, "\nUninstall complete."); 0 } +fn run_cli_uninstall( + opts: &Options, + deps: &Deps, + out: &mut dyn Write, + err_out: &mut dyn Write, +) -> i32 { + let (all_installed, err) = discover(&*deps.runner); + if let Some(e) = err + && opts.debug + { + let _ = writeln!(err_out, "warn: discovery error: {e}"); + } + let mut selected = Vec::new(); + for target in &opts.packages { + let found = all_installed + .iter() + .find(|p| p.name == *target || p.key() == *target); + match found { + Some(pkg) => { + let mut pkg = pkg.clone(); + pkg.remnants_found = remnants::find_remnants_in(deps, &pkg.name); + pkg.remnants_kb = remnants::remnant_size(&pkg.remnants_found) / 1024; + selected.push(pkg); + } + None => { + let _ = writeln!(err_out, "error: package '{target}' not found"); + return 1; + } + } + } + + if opts.dry_run { + let _ = writeln!(out, "Dry run: previewing package removal plan."); + for pkg in &selected { + let _ = writeln!( + out, + "Package to remove: {}:{} ({})", + pkg.source, + pkg.name, + crate::size::human_kb(pkg.installed_kb as u64) + ); + if !pkg.remnants_found.is_empty() { + let _ = writeln!(out, "Remnants to remove:"); + for r in &pkg.remnants_found { + let _ = writeln!(out, " - {r}"); + } + } + } + let results = remove_selected_in(deps, &selected, &all_installed, true, out, err_out); + if let Some(e) = removal_errors(&results) { + let _ = writeln!(err_out, "error: {e}"); + return 1; + } + return 0; + } + + let results = remove_selected_in(deps, &selected, &all_installed, false, out, err_out); + if let Some(e) = removal_errors(&results) { + let _ = writeln!(err_out, "error: {e}"); + return 1; + } + let _ = writeln!(out, "\nUninstall complete."); + 0 +} /// `defer utils.CloseLogger()` — closes on drop at every return path. struct CloseLoggerGuard; @@ -346,6 +448,7 @@ mod tests { let opts = Options { dry_run: false, debug: false, + ..Default::default() }; let mut out = Vec::new(); let mut err_out = Vec::new(); @@ -415,6 +518,7 @@ mod tests { let opts = Options { dry_run: false, debug: false, + ..Default::default() }; let mut out = Vec::new(); let mut err_out = Vec::new(); @@ -443,6 +547,7 @@ mod tests { &Options { dry_run: true, debug: false, + ..Default::default() }, &deps, &mut err_out, @@ -455,4 +560,54 @@ mod tests { ); fs::remove_dir_all(&root).ok(); } + + #[test] + fn run_rejects_permanent_flag_with_code_1() { + let root = tempdir("perm"); + let deps = deps_for_test(&root, Arc::new(FakeRunner::new())); + let mut err_out = Vec::new(); + let code = run_in( + &Options { + permanent: true, + ..Default::default() + }, + &deps, + &mut err_out, + ); + assert_eq!(code, 1); + let text = String::from_utf8_lossy(&err_out); + assert!( + text.contains("error: --permanent is rejected: mu enforces SafeDelete via trash only") + ); + fs::remove_dir_all(&root).ok(); + } + + #[test] + fn run_direct_cli_uninstall_dry_run() { + let root = tempdir("direct-dry"); + let runner = Arc::new(FakeRunner::new()); + runner.set_look_path("dpkg-query", "/usr/bin/dpkg-query"); + runner.set_handler(|spec| { + if spec.program == "dpkg-query" { + return Ok(crate::runner::Output { + stdout: b"install ok installed\t1024\ttest-pkg\t1.0.0\n".to_vec(), + ..Default::default() + }); + } + Ok(crate::runner::Output::default()) + }); + let deps = deps_for_test(&root, runner); + let mut err_out = Vec::new(); + let code = run_in( + &Options { + dry_run: true, + packages: vec!["test-pkg".to_string()], + ..Default::default() + }, + &deps, + &mut err_out, + ); + assert_eq!(code, 0, "stderr: {}", String::from_utf8_lossy(&err_out)); + fs::remove_dir_all(&root).ok(); + } } diff --git a/src/uninstall/remnants.rs b/src/uninstall/remnants.rs index 374d8d3..d947b88 100644 --- a/src/uninstall/remnants.rs +++ b/src/uninstall/remnants.rs @@ -58,12 +58,44 @@ pub(crate) fn find_remnants_in(deps: &Deps, name: &str) -> Vec { let mut seen: HashSet = HashSet::new(); for n in &names { - let candidates = [ + let mut candidates = vec![ deps.config_home.join(n), deps.data_home.join(n), deps.cache_home.join(n), Path::new("/var/lib").join(n), + // systemd user units + deps.config_home + .join("systemd") + .join("user") + .join(format!("{n}.service")), + // desktop entries + deps.data_home + .join("applications") + .join(format!("{n}.desktop")), + deps.config_home + .join("autostart") + .join(format!("{n}.desktop")), ]; + + // Steam app manifests in ~/.local/share/Steam/steamapps/appmanifest_*.acf + let steamapps = deps.data_home.join("Steam").join("steamapps"); + if let Ok(entries) = std::fs::read_dir(&steamapps) { + for e in entries.flatten() { + let p = e.path(); + if let Some(fname) = p.file_name().and_then(|f| f.to_str()) + && fname.starts_with("appmanifest_") + && fname.ends_with(".acf") + { + // Check if file content references game matching n + if let Ok(content) = std::fs::read_to_string(&p) + && content.to_lowercase().contains(&n.to_lowercase()) + { + candidates.push(p); + } + } + } + } + for p in candidates { if !seen.insert(p.clone()) { continue; diff --git a/src/uninstall/remove.rs b/src/uninstall/remove.rs index c918e42..3f26dfa 100644 --- a/src/uninstall/remove.rs +++ b/src/uninstall/remove.rs @@ -115,6 +115,32 @@ pub(crate) fn remove_selected_in( continue; } processed_remnants.insert(remnant.clone()); + + // If remnant is a systemd user unit, stop and disable it before trashing + if remnant.ends_with(".service") { + let unit = Path::new(&remnant) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + if !unit.is_empty() { + if dry_run { + let _ = + writeln!(out, " [dry-run] would run: systemctl --user stop {unit}"); + let _ = writeln!( + out, + " [dry-run] would run: systemctl --user disable {unit}" + ); + } else { + let _ = deps + .runner + .run(&CommandSpec::new("systemctl", ["--user", "stop", unit])); + let _ = deps + .runner + .run(&CommandSpec::new("systemctl", ["--user", "disable", unit])); + } + } + } + // `err = remnantRoot(...); if err == nil { ValidateCleanupCandidate }; // if err == nil { SafeDelete }` — the first failing step wins. let attempt = remnant_root(deps, &remnant) @@ -160,9 +186,10 @@ pub(crate) fn remove_package( out: &mut dyn Write, err_out: &mut dyn Write, ) -> Result<()> { - let args: Vec<&str> = match pkg.source.as_str() { - "apt" => vec!["apt-get", "purge", "-y", pkg.name.as_str()], - "snap" => vec!["snap", "remove", pkg.name.as_str()], + let (prog, args) = match pkg.source.as_str() { + "apt" => ("sudo", vec!["apt-get", "purge", "-y", pkg.name.as_str()]), + "snap" => ("sudo", vec!["snap", "remove", pkg.name.as_str()]), + "flatpak" => ("flatpak", vec!["uninstall", "-y", pkg.name.as_str()]), _ => { return msg(format!( "unsupported package source {:?} for {}", @@ -171,10 +198,15 @@ pub(crate) fn remove_package( } }; if dry_run { - let _ = writeln!(out, " [dry-run] would run: sudo {}", args.join(" ")); + let cmd_str = if prog == "sudo" { + format!("sudo {}", args.join(" ")) + } else { + format!("{prog} {}", args.join(" ")) + }; + let _ = writeln!(out, " [dry-run] would run: {cmd_str}"); return Ok(()); } - let res = deps.runner.run(&CommandSpec::new("sudo", &args)); + let res = deps.runner.run(&CommandSpec::new(prog, &args)); // Go writes result.Stdout→os.Stdout and result.Stderr→os.Stderr // unconditionally; RunError keeps the partial output for the same cases. let output = match &res { @@ -389,4 +421,47 @@ mod tests { ); fs::remove_dir_all(&home).ok(); } + + #[test] + fn flatpak_remove_and_systemd_unit_termination() { + let home = tempdir("flatpak-systemd"); + let runner = Arc::new(FakeRunner::new()); + let deps = deps_for_test(&home, runner.clone()); + let unit_remnant = deps + .config_home + .join("systemd") + .join("user") + .join("flat-app.service"); + fs::create_dir_all(unit_remnant.parent().unwrap()).unwrap(); + fs::write(&unit_remnant, b"[Unit]\nDescription=Test\n").unwrap(); + + let pkg = Package { + name: "org.example.App".to_string(), + source: "flatpak".to_string(), + remnants_found: vec![unit_remnant.to_string_lossy().into_owned()], + ..Default::default() + }; + + let mut out = Vec::new(); + let mut err_out = Vec::new(); + let results = remove_selected_in( + &deps, + std::slice::from_ref(&pkg), + std::slice::from_ref(&pkg), + true, + &mut out, + &mut err_out, + ); + assert!( + removal_errors(&results).is_none(), + "errors: {:?}", + removal_errors(&results) + ); + let text = String::from_utf8_lossy(&out); + assert!(text.contains("[dry-run] would run: flatpak uninstall -y org.example.App")); + assert!(text.contains("[dry-run] would run: systemctl --user stop flat-app.service")); + assert!(text.contains("[dry-run] would run: systemctl --user disable flat-app.service")); + + fs::remove_dir_all(&home).ok(); + } } diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 002d431..bc5e2d9 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -305,3 +305,110 @@ fn analyze_relative_path_dry_run_reports_absolute_target() { let _ = std::fs::remove_dir_all(&test_dir); } + +/// Phase 2: `mu clean --dry-run` creates preview ledger at $XDG_CONFIG_HOME/mu/clean-list.txt. +#[test] +fn clean_dry_run_creates_preview_ledger() { + let o = mu(&["clean", "--dry-run"]); + assert_eq!(o.status.code(), Some(0)); + let out = stdout(&o); + assert!(out.contains("Potential space to free:"), "{out}"); + assert!(out.contains("DRY RUN"), "{out}"); + + let config_home = std::env::var_os("XDG_CONFIG_HOME") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| { + std::env::var_os("HOME") + .map(|h| std::path::PathBuf::from(h).join(".config")) + .unwrap() + }); + let ledger = config_home.join("mu").join("clean-list.txt"); + assert!( + ledger.exists(), + "clean-list.txt must exist at {}", + ledger.display() + ); + let content = std::fs::read_to_string(&ledger).unwrap(); + assert!(content.contains("# mu clean preview ledger")); +} + +/// Phase 2: `mu clean --dry-run --include=system-trash` previews item count and size. +#[test] +fn clean_dry_run_system_trash_previews_count_and_size() { + let o = mu(&["clean", "--dry-run", "--include=system-trash"]); + assert_eq!(o.status.code(), Some(0)); + let out = stdout(&o); + assert!(out.contains("System Trash"), "{out}"); + assert!(out.contains("item"), "{out}"); +} + +/// Phase 2: `mu clean --include=system-trash < /dev/null` fails closed without --yes. +#[test] +fn clean_include_system_trash_aborts_without_yes() { + let o = mu(&["clean", "--include=system-trash"]); + assert_eq!(o.status.code(), Some(0)); + let out = stdout(&o); + assert!(out.contains("Non-interactive: pass --yes"), "{out}"); + assert!(out.contains("Aborted."), "{out}"); +} + +/// Phase 2: Sentinel immutability test for `clean --dry-run`. +#[test] +fn clean_dry_run_sentinel_immutability() { + let test_dir = std::env::temp_dir().join(format!("mu-clean-sentinel-{}", std::process::id())); + let _ = std::fs::create_dir_all(&test_dir); + let sentinel = test_dir.join("keep.txt"); + std::fs::write(&sentinel, b"preserve me").unwrap(); + + let o = mu(&["clean", "--dry-run"]); + assert_eq!(o.status.code(), Some(0)); + assert!( + sentinel.exists(), + "sentinel keep.txt must not be deleted during clean --dry-run" + ); + + let _ = std::fs::remove_dir_all(&test_dir); +} + +/// Phase 2: `mu uninstall --list` lists installed packages with source prefix. +#[test] +fn uninstall_list_outputs_packages() { + let o = mu(&["uninstall", "--list"]); + assert_eq!(o.status.code(), Some(0)); + let out = stdout(&o); + assert!( + out.contains("apt:") || out.contains("snap:") || out.contains("flatpak:"), + "output should list packages with source prefix: {out}" + ); +} + +/// Phase 2: `mu uninstall --dry-run bash` previews removal plan without executing apt. +#[test] +fn uninstall_dry_run_bash_previews_plan() { + let o = mu(&["uninstall", "--dry-run", "bash"]); + assert_eq!(o.status.code(), Some(0)); + let out = stdout(&o); + assert!(out.contains("Package to remove:"), "{out}"); + assert!(out.contains("bash"), "{out}"); + assert!(out.contains("[dry-run]"), "{out}"); +} + +/// Phase 2: `mu uninstall --permanent bash` rejected with exit 1 and safety error. +#[test] +fn uninstall_rejects_permanent_flag() { + let o = mu(&["uninstall", "--permanent", "bash"]); + assert_eq!(o.status.code(), Some(1)); + let err = stderr(&o); + assert!( + err.contains("error: --permanent is rejected: mu enforces SafeDelete via trash only"), + "unexpected stderr: {err}" + ); +} + +/// Phase 2: `mu uninstall --help` renders updated help matching golden fixture. +#[test] +fn uninstall_help_matches_golden() { + let o = mu(&["uninstall", "--help"]); + assert_eq!(o.status.code(), Some(0)); + assert_eq!(stdout(&o), include_str!("golden/help-uninstall.txt")); +} diff --git a/tests/golden/help-uninstall.txt b/tests/golden/help-uninstall.txt index f00e352..5e4c426 100644 --- a/tests/golden/help-uninstall.txt +++ b/tests/golden/help-uninstall.txt @@ -6,11 +6,13 @@ Executes: apt purge + remnant cleanup. Use --dry-run to preview without removing anything. Usage: - mu uninstall [flags] + mu uninstall [flags] [package...] Flags: - --dry-run Preview actions without making changes - -h, --help help for uninstall + --dry-run Preview actions without making changes + -h, --help help for uninstall + --list List installed packages and exit + --permanent Bypass trash (rejected for safety) Global Flags: --debug Enable verbose debug logging From f5de702c2f6a1a80007e1c09ad9e46421ffa020d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ba=20R=E1=BB=8Di?= Date: Fri, 16 Oct 2026 16:16:32 +0700 Subject: [PATCH 06/17] fix(clean-uninstall): resolve MU-003 Phase 2 review findings 1-7 - uninstall CLI: always print removal plan; add -y/--yes fail-closed confirmation gate for direct removal (no unprompted apt-get purge) - uninstall CLI: reject ambiguous bare names across apt/snap/flatpak, require a source prefix - Steam: never trash appmanifests via substring match; parse appid/name and delegate to 'steam steam://uninstall/' on exact name match - clean: implement --external external-volume target (trashes .Trash-*/.directory via SafeDelete, fail-closed on unsafe roots) - system-trash: fail closed when gio is missing instead of silently reporting freed bytes - system-trash: show item count in the interactive preview via CleanTarget::display_label - browser-cache: make PWA protection functional (component-based domain match + recursive delete plan that never removes protected ServiceWorker/IndexedDB storage) Tests added for each fixed behaviour; golden help fixtures updated. make test: 336 unit + 29 integration pass. --- src/clean/flow.rs | 48 ++++++- src/clean/mod.rs | 60 ++++---- src/clean/scan_browser.rs | 136 +++++++++++++++--- src/clean/scan_docker.rs | 1 + src/clean/scan_kernels.rs | 1 + src/clean/scan_snap.rs | 1 + src/clean/targets.rs | 147 +++++++++++++++++++- src/cli.rs | 47 ++++++- src/tui/mod.rs | 1 + src/uninstall/mod.rs | 239 ++++++++++++++++++++++++++++---- src/uninstall/remnants.rs | 142 ++++++++++++++++--- src/uninstall/remove.rs | 90 ++++++++++++ tests/cli_integration.rs | 22 +++ tests/golden/help-clean.txt | 1 + tests/golden/help-uninstall.txt | 1 + 15 files changed, 834 insertions(+), 103 deletions(-) diff --git a/src/clean/flow.rs b/src/clean/flow.rs index 62c51fc..6371c91 100644 --- a/src/clean/flow.rs +++ b/src/clean/flow.rs @@ -465,7 +465,7 @@ impl FlowModel { let done = self.results.len(); if done > 0 { let label = if done < self.targets.len() { - format!(": {}", self.targets[done].label) + format!(": {}", self.targets[done].display_label()) } else { String::new() }; @@ -491,7 +491,7 @@ impl FlowModel { let mut label_w = self .targets .iter() - .map(|t| t.label.chars().count()) + .map(|t| t.display_label().chars().count()) .max() .unwrap_or(0); let max = self.width.saturating_sub(size_w + 6); @@ -514,7 +514,7 @@ impl FlowModel { ), ]; for (i, res) in self.results.iter().enumerate() { - let label = truncate(self.targets[i].label, label_w as isize); + let label = truncate(&self.targets[i].display_label(), label_w as isize); lines.push(Line::raw(format!( " {label}{}{:>size_w$}", pad(&label), @@ -802,6 +802,7 @@ mod tests { Ok(()) } }), + count_fn: None, } }) .collect() @@ -1047,6 +1048,7 @@ mod tests { ) })), execute: Box::new(|_| Ok(())), + count_fn: None, }; let m = FlowModel::new(Options::default(), vec![target]); let Msg::ScanTarget(sm) = m.scan_cmd(0) else { @@ -1143,6 +1145,7 @@ mod tests { scan: Box::new(|| (0, None)), preview: None, execute: Box::new(|_| Ok(())), + count_fn: None, } } fn plain_target() -> CleanTarget { @@ -1154,6 +1157,7 @@ mod tests { scan: Box::new(|| (0, None)), preview: None, execute: Box::new(|_| Ok(())), + count_fn: None, } } @@ -1199,4 +1203,42 @@ mod tests { }; assert!(done.err.is_none()); } + + // Finding 6: the interactive summary must render the dynamic + // system-trash item count (`display_label`), not the static label. + #[test] + fn summary_shows_system_trash_item_count() { + let target = CleanTarget { + id: "system-trash", + label: "System Trash", + requires_sudo: false, + opt_in: true, + scan: Box::new(|| (SIZE_A, None)), + preview: None, + execute: Box::new(|_| Ok(())), + count_fn: Some(Box::new(|| "System Trash (2 items)".to_string())), + }; + let mut m = FlowModel::new(Options::default(), vec![target]); + m.results = vec![Scanned { + size: SIZE_A, + items: Vec::new(), + }]; + m.total = SIZE_A; + m.state = FlowState::Summary; + let text: String = m + .view_lines() + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n"); + assert!( + text.contains("System Trash (2 items)"), + "summary missing dynamic count label: {text:?}" + ); + } } diff --git a/src/clean/mod.rs b/src/clean/mod.rs index 56e9588..b00c294 100644 --- a/src/clean/mod.rs +++ b/src/clean/mod.rs @@ -53,6 +53,20 @@ pub struct CleanTarget { /// while `runPlain` drops errored previews). pub preview: Option, pub execute: Box Result<()>>, + /// Optional dynamic label (e.g. `System Trash (14 items)`) computed at + /// render time; `None` falls back to the static [`label`]. + pub count_fn: Option String>>, +} + +impl CleanTarget { + /// The label shown in plain/TUI previews — [`count_fn`] when present + /// (fresh item count), otherwise the static [`label`]. + pub fn display_label(&self) -> String { + match &self.count_fn { + Some(f) => f(), + None => self.label.to_string(), + } + } } /// Preview closure payload — Go's `([]string, error)` pair. @@ -79,6 +93,8 @@ pub struct Options { pub include: Vec, /// Skip the confirmation prompt. pub auto_yes: bool, + /// `--external ` — clean metadata on an external volume mount. + pub external: Option, } /// Dependency bundle replacing the Go package vars. `runner` is @@ -224,13 +240,21 @@ fn run_tui_in(opts: &Options, targets: Vec, deps: &Deps) -> Result< } /// The pre-fork half of `Run`: `LoadWhitelist` (fail closed on malformed -/// config), `ValidateCleanupRoot(XDGCacheHome())`, `ResolveTargets`. +/// config), `ValidateCleanupRoot(XDGCacheHome())`, `ResolveTargets`, then the +/// `--external ` target when a mount path was given. fn prepare(opts: &Options, deps: &Deps) -> Result> { if let Err(e) = config::load_whitelist_from(&deps.config_home) { return Err(Error::Msg(format!("invalid mu configuration: {e}"))); } paths::validate_cleanup_root(&deps.cache_home)?; - resolve_targets_in(deps, &opts.include) + let mut targets = resolve_targets_in(deps, &opts.include)?; + if let Some(mount) = &opts.external { + // Fail closed before the target is even built: the mount must be a + // safe cleanup root (absolute, not home/an ancestor, not protected). + paths::validate_cleanup_root(mount)?; + targets.push(targets::external_volume_target_in(deps, mount)); + } + Ok(targets) } /// Injectable `runPlain` for tests: `deps` supplies the XDG roots and @@ -299,15 +323,7 @@ fn run_plain( }); for res in &results { - let label = if res.target.id == "system-trash" { - let count = targets::count_trash_items(&deps.data_home); - format!( - "System Trash ({count} item{})", - if count == 1 { "" } else { "s" } - ) - } else { - res.target.label.to_string() - }; + let label = res.target.display_label(); r.line(format_args!("{:<40} {}", label, size::human_size(res.size)))?; for item in &res.items { r.line(format_args!(" - {item}"))?; @@ -318,7 +334,7 @@ fn run_plain( "Potential space to free: {}", size::human_size(total) ))?; - export_preview_ledger(&results, total, &deps.config_home, &deps.data_home); + export_preview_ledger(&results, total, &deps.config_home); if !scan_errors.is_empty() { return Err(join_errors(&scan_errors)); } @@ -349,12 +365,7 @@ fn run_plain( Ok(format!("Freed {}", size::human_size(freed))) } -fn export_preview_ledger( - results: &[ScanResult], - total: i64, - config_home: &std::path::Path, - data_home: &std::path::Path, -) { +fn export_preview_ledger(results: &[ScanResult], total: i64, config_home: &std::path::Path) { let mu_dir = config_home.join("mu"); let _ = std::fs::create_dir_all(&mu_dir); let ledger_path = mu_dir.join("clean-list.txt"); @@ -370,15 +381,7 @@ fn export_preview_ledger( ); let _ = writeln!(f, "--------------------------------------------------"); for res in results { - let label = if res.target.id == "system-trash" { - let count = targets::count_trash_items(data_home); - format!( - "System Trash ({count} item{})", - if count == 1 { "" } else { "s" } - ) - } else { - res.target.label.to_string() - }; + let label = res.target.display_label(); let _ = writeln!(f, "{:<40} {}", label, size::human_size(res.size)); for item in &res.items { let _ = writeln!(f, " - {item}"); @@ -621,6 +624,7 @@ mod tests { scan: Box::new(|| (0, None)), preview: None, execute: Box::new(execute), + count_fn: None, } } @@ -846,7 +850,7 @@ mod tests { size: sz, items: vec!["item1.txt".into(), "item2.txt".into()], }; - export_preview_ledger(&[res], sz, &deps.config_home, &deps.data_home); + export_preview_ledger(&[res], sz, &deps.config_home); let ledger = tmp.join(".config").join("mu").join("clean-list.txt"); assert!(ledger.exists(), "clean-list.txt should be created"); let content = fs::read_to_string(&ledger).unwrap(); diff --git a/src/clean/scan_browser.rs b/src/clean/scan_browser.rs index 6a97a04..eb56580 100644 --- a/src/clean/scan_browser.rs +++ b/src/clean/scan_browser.rs @@ -14,16 +14,63 @@ use super::{CleanTarget, Deps, join_errors}; /// must not be removed to prevent losing offline PWA state. const PROTECTED_PWA_DOMAINS: &[&str] = &["google", "docs.google.com", "github", "figma", "linear"]; +/// Index of the ServiceWorker/IndexedDB storage marker in `components`, if any. +fn pwa_marker_index(components: &[String]) -> Option { + components + .iter() + .position(|c| c == "service worker" || c == "serviceworker" || c == "indexeddb") +} + /// Returns true if the path points to ServiceWorker or IndexedDB data belonging /// to a protected productivity domain. +/// +/// Matching is component-based: the domain is tested only against path +/// components at/after the storage marker, so a `google-chrome` profile +/// directory never matches while `IndexedDB/https_docs.google.com_0...` does. pub(crate) fn is_protected_pwa_path(path: &Path) -> bool { - let s = path.to_string_lossy().to_lowercase(); - let is_pwa_storage = - s.contains("service worker") || s.contains("serviceworker") || s.contains("indexeddb"); - if !is_pwa_storage { + let components: Vec = path + .components() + .map(|c| c.as_os_str().to_string_lossy().to_lowercase()) + .collect(); + let Some(idx) = pwa_marker_index(&components) else { return false; + }; + components[idx..] + .iter() + .any(|c| PROTECTED_PWA_DOMAINS.iter().any(|d| c.contains(d))) +} + +/// True when `dir` is, or contains, protected PWA storage. Symlinked entries +/// are not followed (a symlinked cache must not pull the walk outside). +fn contains_protected_pwa(dir: &Path) -> bool { + if is_protected_pwa_path(dir) { + return true; } - PROTECTED_PWA_DOMAINS.iter().any(|d| s.contains(d)) + let Ok(entries) = std::fs::read_dir(dir) else { + return false; + }; + entries.flatten().any(|e| { + e.file_type().map(|t| t.is_dir()).unwrap_or(false) && contains_protected_pwa(&e.path()) + }) +} + +/// The paths `execute` may trash for a cache candidate. A directory with no +/// protected PWA descendant is deleted whole; otherwise the walk descends so +/// protected PWA subtrees are never included while their siblings are. +pub(crate) fn browser_delete_plan(dir: &Path) -> Vec { + if !contains_protected_pwa(dir) { + return vec![dir.to_path_buf()]; + } + let mut plan = Vec::new(); + let Ok(entries) = std::fs::read_dir(dir) else { + return plan; + }; + let mut entries: Vec<_> = entries.flatten().collect(); + entries.sort_by_key(|e| e.file_name()); + for e in entries { + plan.extend(browser_delete_plan(&e.path())); + } + plan } /// Helper to find Firefox-like `*/cache2` profile directories under a root. @@ -164,10 +211,15 @@ pub(crate) fn browser_cache_target_in(deps: &Deps) -> CleanTarget { let mut total: i64 = 0; let mut scan_errors: Vec = Vec::new(); for p in browser_cache_paths_in(&scan_home) { - let (sz, err) = crate::size::dir_size(&p); - match err { - None => total += sz as i64, - Some(e) => scan_errors.push(e), + for planned in browser_delete_plan(&p) { + if is_protected_pwa_path(&planned) { + continue; + } + let (sz, err) = crate::size::dir_size(&planned); + match err { + None => total += sz as i64, + Some(e) => scan_errors.push(e), + } } } if scan_errors.is_empty() { @@ -184,14 +236,23 @@ pub(crate) fn browser_cache_target_in(deps: &Deps) -> CleanTarget { continue; } let root = browser_cleanup_root(&exec_home, &p); - if let Err(e) = paths::validate_cleanup_candidate(&root, &p) { - delete_errors.push(e); - continue; - } - if let Err(e) = - trash::safe_delete_with(&*exec_runner, &exec_cfg, &exec_deps, &p, dry_run) - { - delete_errors.push(e); + for planned in browser_delete_plan(&p) { + if is_protected_pwa_path(&planned) { + continue; + } + if let Err(e) = paths::validate_cleanup_candidate(&root, &planned) { + delete_errors.push(e); + continue; + } + if let Err(e) = trash::safe_delete_with( + &*exec_runner, + &exec_cfg, + &exec_deps, + &planned, + dry_run, + ) { + delete_errors.push(e); + } } } if delete_errors.is_empty() { @@ -200,6 +261,7 @@ pub(crate) fn browser_cache_target_in(deps: &Deps) -> CleanTarget { Err(join_errors(&delete_errors)) } }), + count_fn: None, } } @@ -319,6 +381,46 @@ mod tests { assert!(!is_protected_pwa_path(Path::new( "/home/user/.mozilla/firefox/abc/cache2" ))); + // Component-based matching: an unprotected domain under IndexedDB is + // not protected, and a `google-chrome` profile component alone is not. + assert!(!is_protected_pwa_path(Path::new( + "/home/user/.config/google-chrome/Default/IndexedDB/https_evil.com_0.indexeddb.leveldb" + ))); + assert!(!is_protected_pwa_path(Path::new( + "/home/user/.var/app/com.google.Chrome/cache/Default/Cache" + ))); + assert!(is_protected_pwa_path(Path::new( + "/home/user/.config/google-chrome/Default/IndexedDB/https_linear.app_0.indexeddb.leveldb" + ))); + } + + // Finding 4: protected PWA storage inside a browser cache root must + // survive a real clean while the rest of the cache is removed. + #[test] + fn pwa_storage_survives_browser_cache_clean() { + let tmp = tempdir("pwa"); + let cache = tmp + .join(".var") + .join("app") + .join("com.google.Chrome") + .join("cache"); + let plain = cache.join("Default").join("Cache"); + fs::create_dir_all(&plain).unwrap(); + fs::write(plain.join("leaf"), "x").unwrap(); + let pwa = cache + .join("Default") + .join("Service Worker") + .join("CacheStorage") + .join("docs.google.com"); + fs::create_dir_all(&pwa).unwrap(); + fs::write(pwa.join("sw"), "offline").unwrap(); + + let deps = deps_for_test(&tmp, Arc::new(FakeRunner::new())); + let target = browser_cache_target_in(&deps); + (target.execute)(false).expect("execute"); + assert!(pwa.join("sw").exists(), "protected PWA storage was deleted"); + assert!(!plain.exists(), "plain cache should have been removed"); + fs::remove_dir_all(&tmp).ok(); } #[test] diff --git a/src/clean/scan_docker.rs b/src/clean/scan_docker.rs index 859de17..86dd1c2 100644 --- a/src/clean/scan_docker.rs +++ b/src/clean/scan_docker.rs @@ -100,6 +100,7 @@ pub(crate) fn new_docker_target_in(deps: &Deps) -> CleanTarget { } } }), + count_fn: None, } } diff --git a/src/clean/scan_kernels.rs b/src/clean/scan_kernels.rs index d8a1285..841b8f2 100644 --- a/src/clean/scan_kernels.rs +++ b/src/clean/scan_kernels.rs @@ -256,6 +256,7 @@ pub(crate) fn kernels_target_in(deps: &Deps) -> CleanTarget { ) })), execute: Box::new(move |dry_run| run_autoremove(&*runner, dry_run)), + count_fn: None, } } diff --git a/src/clean/scan_snap.rs b/src/clean/scan_snap.rs index a75ee47..744c9fb 100644 --- a/src/clean/scan_snap.rs +++ b/src/clean/scan_snap.rs @@ -126,6 +126,7 @@ pub(crate) fn snap_target_in(deps: &Deps) -> CleanTarget { Err(join_errors(&remove_errors)) } }), + count_fn: None, } } diff --git a/src/clean/targets.rs b/src/clean/targets.rs index 256ace6..11f5712 100644 --- a/src/clean/targets.rs +++ b/src/clean/targets.rs @@ -114,6 +114,7 @@ pub(crate) fn user_cache_target_in(deps: &Deps) -> CleanTarget { Err(super::join_errors(&delete_errors)) } }), + count_fn: None, } } @@ -198,6 +199,7 @@ pub(crate) fn thumbnails_target_in(deps: &Deps) -> CleanTarget { paths::validate_cleanup_candidate(&exec_root, &exec_thumb)?; trash::safe_delete_with(&*exec_runner, &exec_cfg, &exec_deps, &exec_thumb, dry_run) }), + count_fn: None, } } @@ -232,6 +234,7 @@ pub(crate) fn apt_cache_target_in(deps: &Deps) -> CleanTarget { } } }), + count_fn: None, } } @@ -271,6 +274,7 @@ pub(crate) fn journal_logs_target_in(deps: &Deps) -> CleanTarget { } } }), + count_fn: None, } } @@ -347,6 +351,7 @@ pub(crate) fn font_cache_target_in(deps: &Deps) -> CleanTarget { } } }), + count_fn: None, } } @@ -465,6 +470,7 @@ pub(crate) fn sandbox_font_cache_target_in(deps: &Deps) -> CleanTarget { Err(super::join_errors(&delete_errors)) } }), + count_fn: None, } } @@ -524,6 +530,7 @@ pub(crate) fn system_trash_size(data_home: &Path) -> i64 { pub(crate) fn system_trash_target_in(deps: &Deps) -> CleanTarget { let scan_data = deps.data_home.clone(); let exec_runner = Arc::clone(&deps.runner); + let count_data = deps.data_home.clone(); CleanTarget { id: "system-trash", @@ -539,14 +546,103 @@ pub(crate) fn system_trash_target_in(deps: &Deps) -> CleanTarget { if dry_run { return Ok(()); } - if exec_runner.look_path("gio").is_ok() { - exec_runner - .run(&CommandSpec::new("gio", ["trash", "--empty"])) - .map(|_| ()) - .map_err(|e| Error::Msg(format!("empty trash: {e}")))?; + // Fail closed: without `gio` the trash cannot be emptied, so this + // must be a failure — not a silent no-op that `execute` would + // still credit as freed space. + if exec_runner.look_path("gio").is_err() { + return Err(Error::Msg( + "gio not found: cannot empty system trash".to_string(), + )); } - Ok(()) + exec_runner + .run(&CommandSpec::new("gio", ["trash", "--empty"])) + .map(|_| ()) + .map_err(|e| Error::Msg(format!("empty trash: {e}"))) }), + count_fn: Some(Box::new(move || { + let count = count_trash_items(&count_data); + format!( + "System Trash ({count} item{})", + if count == 1 { "" } else { "s" } + ) + })), + } +} + +/// `externalVolumeCandidates` — removable metadata directly under an external +/// mount: `.Trash-` directories and `.directory` files. +pub(crate) fn external_volume_candidates(mount: &Path) -> Vec { + let mut found = Vec::new(); + let Ok(entries) = std::fs::read_dir(mount) else { + return found; + }; + let mut entries: Vec<_> = entries.flatten().collect(); + entries.sort_by_key(|e| e.file_name()); + for e in entries { + let name = e.file_name(); + let name = name.to_string_lossy(); + if name.starts_with(".Trash-") || name == ".directory" { + found.push(e.path()); + } + } + found +} + +/// `externalVolumeTarget` — clean target added by `mu clean --external `: +/// removes `.Trash-*` and `.directory` metadata on an external volume mount, +/// strictly through `SafeDelete` (never `rm`). +pub(crate) fn external_volume_target_in(deps: &Deps, mount: &Path) -> CleanTarget { + let scan_mount = mount.to_path_buf(); + let prev_mount = mount.to_path_buf(); + let exec_mount = mount.to_path_buf(); + let exec_cfg = deps.config_home.clone(); + let exec_runner = Arc::clone(&deps.trash_runner); + let exec_deps = deps.trash_deps.clone(); + + CleanTarget { + id: "external-volume", + label: "External Volume Metadata", + requires_sudo: false, + // Only ever present when `--external` names the mount. + opt_in: true, + scan: Box::new(move || { + let mut total = 0i64; + for p in external_volume_candidates(&scan_mount) { + let (sz, _) = size::dir_size(&p); + total += sz as i64; + } + (total, None) + }), + preview: Some(Box::new(move || { + ( + external_volume_candidates(&prev_mount) + .into_iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(), + None, + ) + })), + execute: Box::new(move |dry_run| { + paths::validate_cleanup_root(&exec_mount)?; + let mut delete_errors: Vec = Vec::new(); + for p in external_volume_candidates(&exec_mount) { + if let Err(e) = paths::validate_cleanup_candidate(&exec_mount, &p) { + delete_errors.push(e); + continue; + } + if let Err(e) = + trash::safe_delete_with(&*exec_runner, &exec_cfg, &exec_deps, &p, dry_run) + { + delete_errors.push(e); + } + } + if delete_errors.is_empty() { + Ok(()) + } else { + Err(super::join_errors(&delete_errors)) + } + }), + count_fn: None, } } @@ -974,4 +1070,43 @@ mod tests { ["LC_ALL=C", "journalctl", "--disk-usage"].map(std::ffi::OsString::from) ); } + + // Finding 5: without `gio` the trash target must fail instead of reporting + // a silent no-op success that `execute` credits as freed space. + #[test] + fn system_trash_fails_closed_without_gio() { + let tmp = tempdir("trash-nogio"); + let deps = deps_for_test(&tmp, Arc::new(FakeRunner::new())); // gio missing + let target = system_trash_target_in(&deps); + let err = (target.execute)(false).expect_err("expected gio-missing failure"); + assert!( + err.to_string().contains("gio not found"), + "unexpected error: {err}" + ); + fs::remove_dir_all(&tmp).ok(); + } + + // Finding 2: `--external ` target trashes `.Trash-*`/`.directory`. + #[test] + fn external_volume_target_cleans_metadata() { + let tmp = tempdir("external"); + let mount = tmp.join("mnt"); + fs::create_dir_all(mount.join(".Trash-1000")).unwrap(); + fs::write(mount.join(".Trash-1000").join("f"), "x").unwrap(); + fs::write(mount.join(".directory"), "meta").unwrap(); + let runner = Arc::new(FakeRunner::new()); + let deps = deps_for_test(&tmp, runner); + let target = external_volume_target_in(&deps, &mount); + let (sz, err) = (target.scan)(); + assert!(err.is_none(), "scan error: {err:?}"); + assert!(sz > 0, "expected non-zero scan size, got {sz}"); + + (target.execute)(true).expect("dry-run execute"); + assert!(mount.join(".directory").exists(), "dry-run deleted a file"); + + (target.execute)(false).expect("real execute"); + assert!(!mount.join(".directory").exists(), ".directory not removed"); + assert!(!mount.join(".Trash-1000").exists(), ".Trash-* not removed"); + fs::remove_dir_all(&tmp).ok(); + } } diff --git a/src/cli.rs b/src/cli.rs index eecb319..c06a689 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -129,6 +129,7 @@ Usage: Flags: --dry-run Preview actions without making changes + --external string Clean metadata on an external volume (mount path) -h, --help help for clean --include strings Opt-in categories (e.g. browser-cache) -y, --yes Skip confirmation prompt @@ -200,6 +201,7 @@ Flags: -h, --help help for uninstall --list List installed packages and exit --permanent Bypass trash (rejected for safety) + -y, --yes Skip confirmation prompt Global Flags: --debug Enable verbose debug logging @@ -435,6 +437,7 @@ enum Command { dry_run: bool, include: Vec, yes: bool, + external: Option, }, Optimize { dry_run: bool, @@ -452,6 +455,7 @@ enum Command { list: bool, permanent: bool, packages: Vec, + yes: bool, }, } @@ -531,12 +535,14 @@ pub fn run() -> ! { dry_run, include, yes, + external, } => { let code = match clean::run(&clean::Options { dry_run, debug, include, auto_yes: yes, + external, }) { Ok(_) => 0, Err(e) => { @@ -563,12 +569,14 @@ pub fn run() -> ! { list, permanent, packages, + yes, } => std::process::exit(uninstall::run(&uninstall::Options { dry_run, debug, list, permanent, packages, + auto_yes: yes, })), } } @@ -702,6 +710,12 @@ const F_LIST: FlagSpec = FlagSpec { takes_value: false, display: "--list", }; +const F_EXTERNAL: FlagSpec = FlagSpec { + long: "external", + short: None, + takes_value: true, + display: "--external", +}; const ROOT_FLAGS: &[FlagSpec] = &[F_DEBUG, F_HELP, F_VERSION]; const HELP_FLAGS: &[FlagSpec] = &[F_DEBUG, F_HELP]; @@ -712,10 +726,10 @@ fn cmd_flagset(name: &str) -> &'static [FlagSpec] { match name { "analyze" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_JSON], "status" => &[F_DEBUG, F_HELP, F_JSON], - "clean" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_YES, F_INCLUDE], + "clean" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_YES, F_INCLUDE, F_EXTERNAL], "optimize" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_YES, F_SKIP], "audit" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_JSON, F_REPORT, F_INCLUDE], - "uninstall" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_PERMANENT, F_LIST], + "uninstall" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_PERMANENT, F_LIST, F_YES], _ => &[], } } @@ -1043,6 +1057,7 @@ fn exec_subcommand(name: &str, args: &[String]) -> Outcome { dry_run: p.get("dry-run"), include: p.vals("include"), yes: p.get("yes"), + external: p.vals("external").first().map(std::path::PathBuf::from), }, "optimize" => Command::Optimize { dry_run: p.get("dry-run"), @@ -1059,6 +1074,7 @@ fn exec_subcommand(name: &str, args: &[String]) -> Outcome { dry_run: p.get("dry-run"), list: p.get("list"), permanent: p.get("permanent"), + yes: p.get("yes"), packages: p.positionals, }, _ => unreachable!(), @@ -1823,6 +1839,33 @@ mod tests { _ => panic!("expected Run"), } } + + #[test] + fn clean_external_flag_parses() { + match parse(&args(&["clean", "--external", "/mnt/usb"])) { + Outcome::Run { cmd, .. } => match cmd { + Command::Clean { external, .. } => { + assert_eq!(external, Some(std::path::PathBuf::from("/mnt/usb"))); + } + _ => panic!("expected Clean"), + }, + _ => panic!("expected Run"), + } + } + + #[test] + fn uninstall_yes_flag_parses() { + match parse(&args(&["uninstall", "--yes", "bash"])) { + Outcome::Run { cmd, .. } => match cmd { + Command::Uninstall { yes, packages, .. } => { + assert!(yes); + assert_eq!(packages, vec!["bash"]); + } + _ => panic!("expected Uninstall"), + }, + _ => panic!("expected Run"), + } + } #[test] fn analyze_flag_parsing() { match parse(&args(&["analyze", "--json", "/var/log"])) { diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 640eaaf..3fd297e 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -410,6 +410,7 @@ fn dispatch_subcommand(command: &str, debug: bool) -> anyhow::Result, + /// `Yes` — skip the confirmation gate for direct CLI removal. + pub auto_yes: bool, } /// Dependency bundle replacing the Go package vars. `runner` is @@ -298,40 +300,53 @@ fn run_cli_uninstall( } let mut selected = Vec::new(); for target in &opts.packages { - let found = all_installed - .iter() - .find(|p| p.name == *target || p.key() == *target); - match found { - Some(pkg) => { - let mut pkg = pkg.clone(); - pkg.remnants_found = remnants::find_remnants_in(deps, &pkg.name); - pkg.remnants_kb = remnants::remnant_size(&pkg.remnants_found) / 1024; - selected.push(pkg); - } - None => { + // A source-qualified key (`apt:foo`, `snap:foo`) is unambiguous. + if let Some(pkg) = all_installed.iter().find(|p| p.key() == *target) { + selected.push(with_remnants(deps, pkg)); + continue; + } + let by_name: Vec<&Package> = all_installed.iter().filter(|p| p.name == *target).collect(); + match by_name.as_slice() { + [] => { let _ = writeln!(err_out, "error: package '{target}' not found"); return 1; } + [pkg] => selected.push(with_remnants(deps, pkg)), + many => { + // Fail closed instead of silently purging whichever source + // sorts first. + let keys: Vec = many.iter().map(|p| p.key()).collect(); + let _ = writeln!( + err_out, + "error: '{target}' is ambiguous; matches {} — specify a source prefix (e.g. {})", + keys.join(", "), + keys[0] + ); + return 1; + } } } - if opts.dry_run { - let _ = writeln!(out, "Dry run: previewing package removal plan."); - for pkg in &selected { - let _ = writeln!( - out, - "Package to remove: {}:{} ({})", - pkg.source, - pkg.name, - crate::size::human_kb(pkg.installed_kb as u64) - ); - if !pkg.remnants_found.is_empty() { - let _ = writeln!(out, "Remnants to remove:"); - for r in &pkg.remnants_found { - let _ = writeln!(out, " - {r}"); - } + // Always print the removal plan so the action is previewable before any + // package is touched. + for pkg in &selected { + let _ = writeln!( + out, + "Package to remove: {}:{} ({})", + pkg.source, + pkg.name, + crate::size::human_kb(pkg.installed_kb as u64) + ); + if !pkg.remnants_found.is_empty() { + let _ = writeln!(out, "Remnants to remove:"); + for r in &pkg.remnants_found { + let _ = writeln!(out, " - {r}"); } } + } + + if opts.dry_run { + let _ = writeln!(out, "Dry run: previewing package removal plan."); let results = remove_selected_in(deps, &selected, &all_installed, true, out, err_out); if let Some(e) = removal_errors(&results) { let _ = writeln!(err_out, "error: {e}"); @@ -340,6 +355,34 @@ fn run_cli_uninstall( return 0; } + // Fail-closed confirmation gate (finding: unprompted `apt-get purge -y`). + if !opts.auto_yes { + if std::io::stdin().is_terminal() { + let _ = write!( + err_out, + "Proceed with removal of {} package(s)? [y/N] ", + selected.len() + ); + let _ = err_out.flush(); + let mut line = String::new(); + let confirmed = std::io::stdin().lock().read_line(&mut line).is_ok_and(|_| { + let t = line.trim().to_ascii_lowercase(); + t == "y" || t == "yes" + }); + if !confirmed { + let _ = writeln!(out, "Aborted."); + return 0; + } + } else { + let _ = writeln!( + out, + "Non-interactive: pass --yes to confirm, or --dry-run to preview." + ); + let _ = writeln!(out, "Aborted."); + return 0; + } + } + let results = remove_selected_in(deps, &selected, &all_installed, false, out, err_out); if let Some(e) = removal_errors(&results) { let _ = writeln!(err_out, "error: {e}"); @@ -349,6 +392,15 @@ fn run_cli_uninstall( 0 } +/// Clone a discovered package and attach its remnant scan (shared by every +/// direct-CLI resolution branch). +fn with_remnants(deps: &Deps, pkg: &Package) -> Package { + let mut pkg = pkg.clone(); + pkg.remnants_found = remnants::find_remnants_in(deps, &pkg.name); + pkg.remnants_kb = remnants::remnant_size(&pkg.remnants_found) / 1024; + pkg +} + /// `defer utils.CloseLogger()` — closes on drop at every return path. struct CloseLoggerGuard; @@ -610,4 +662,137 @@ mod tests { assert_eq!(code, 0, "stderr: {}", String::from_utf8_lossy(&err_out)); fs::remove_dir_all(&root).ok(); } + + fn runner_with_pkg(pkgs: &'static str) -> Arc { + let runner = Arc::new(FakeRunner::new()); + runner.set_look_path("dpkg-query", "/usr/bin/dpkg-query"); + runner.set_handler(move |spec| { + if spec.program == "dpkg-query" { + return Ok(crate::runner::Output { + stdout: pkgs.as_bytes().to_vec(), + ..Default::default() + }); + } + Ok(crate::runner::Output::default()) + }); + runner + } + + fn purge_invoked(runner: &FakeRunner, name: &str) -> bool { + runner.invocations().iter().any(|s| { + s.program == "sudo" + && s.args == ["apt-get", "purge", "-y", name].map(std::ffi::OsString::from) + }) + } + + // Finding 1: direct CLI removal must not purge without `--yes` + // (fail-closed), mirroring clean's non-interactive gate. + #[test] + fn run_cli_uninstall_requires_yes_gate() { + let root = tempdir("gate"); + let runner = runner_with_pkg("install ok installed\t1024\ttest-pkg\t1.0.0\n"); + let deps = deps_for_test(&root, runner.clone()); + let mut out = Vec::new(); + let mut err_out = Vec::new(); + let code = run_cli_uninstall( + &Options { + packages: vec!["test-pkg".to_string()], + auto_yes: false, + ..Default::default() + }, + &deps, + &mut out, + &mut err_out, + ); + assert_eq!(code, 0, "stderr: {}", String::from_utf8_lossy(&err_out)); + assert!( + String::from_utf8_lossy(&out).contains("Aborted."), + "expected abort note, got {:?}", + String::from_utf8_lossy(&out) + ); + assert!( + !purge_invoked(&runner, "test-pkg"), + "purge ran without --yes: {:?}", + runner.invocations() + ); + fs::remove_dir_all(&root).ok(); + } + + #[test] + fn run_cli_uninstall_with_yes_executes() { + let root = tempdir("yes"); + let runner = runner_with_pkg("install ok installed\t1024\ttest-pkg\t1.0.0\n"); + let deps = deps_for_test(&root, runner.clone()); + let mut out = Vec::new(); + let mut err_out = Vec::new(); + let code = run_cli_uninstall( + &Options { + packages: vec!["test-pkg".to_string()], + auto_yes: true, + ..Default::default() + }, + &deps, + &mut out, + &mut err_out, + ); + assert_eq!(code, 0, "stderr: {}", String::from_utf8_lossy(&err_out)); + assert!(purge_invoked(&runner, "test-pkg"), "expected purge call"); + fs::remove_dir_all(&root).ok(); + } + + // Finding 7: a bare name matching several sources must fail closed. + #[test] + fn run_cli_uninstall_rejects_ambiguous_name() { + let root = tempdir("ambiguous"); + let runner = Arc::new(FakeRunner::new()); + runner.set_look_path("dpkg-query", "/usr/bin/dpkg-query"); + runner.set_look_path("snap", "/usr/bin/snap"); + runner.set_handler(|spec| match spec.program.to_string_lossy().as_ref() { + "dpkg-query" => Ok(crate::runner::Output { + stdout: b"install ok installed\t1024\tfirefox\t1.0\n".to_vec(), + ..Default::default() + }), + "snap" => Ok(crate::runner::Output { + stdout: + b"Name Version Rev Tracking Publisher Notes\nfirefox 124 1 stable mozilla -\n" + .to_vec(), + ..Default::default() + }), + _ => Ok(crate::runner::Output::default()), + }); + let deps = deps_for_test(&root, runner.clone()); + let mut out = Vec::new(); + let mut err_out = Vec::new(); + let code = run_cli_uninstall( + &Options { + dry_run: true, + packages: vec!["firefox".to_string()], + ..Default::default() + }, + &deps, + &mut out, + &mut err_out, + ); + assert_eq!(code, 1); + let text = String::from_utf8_lossy(&err_out); + assert!( + text.contains("ambiguous"), + "expected ambiguity error: {text}" + ); + // A source prefix still resolves. + let mut out = Vec::new(); + let mut err_out = Vec::new(); + let code = run_cli_uninstall( + &Options { + dry_run: true, + packages: vec!["snap:firefox".to_string()], + ..Default::default() + }, + &deps, + &mut out, + &mut err_out, + ); + assert_eq!(code, 0, "stderr: {}", String::from_utf8_lossy(&err_out)); + fs::remove_dir_all(&root).ok(); + } } diff --git a/src/uninstall/remnants.rs b/src/uninstall/remnants.rs index d947b88..db6d781 100644 --- a/src/uninstall/remnants.rs +++ b/src/uninstall/remnants.rs @@ -58,7 +58,7 @@ pub(crate) fn find_remnants_in(deps: &Deps, name: &str) -> Vec { let mut seen: HashSet = HashSet::new(); for n in &names { - let mut candidates = vec![ + let candidates = vec![ deps.config_home.join(n), deps.data_home.join(n), deps.cache_home.join(n), @@ -77,25 +77,6 @@ pub(crate) fn find_remnants_in(deps: &Deps, name: &str) -> Vec { .join(format!("{n}.desktop")), ]; - // Steam app manifests in ~/.local/share/Steam/steamapps/appmanifest_*.acf - let steamapps = deps.data_home.join("Steam").join("steamapps"); - if let Ok(entries) = std::fs::read_dir(&steamapps) { - for e in entries.flatten() { - let p = e.path(); - if let Some(fname) = p.file_name().and_then(|f| f.to_str()) - && fname.starts_with("appmanifest_") - && fname.ends_with(".acf") - { - // Check if file content references game matching n - if let Ok(content) = std::fs::read_to_string(&p) - && content.to_lowercase().contains(&n.to_lowercase()) - { - candidates.push(p); - } - } - } - } - for p in candidates { if !seen.insert(p.clone()) { continue; @@ -125,6 +106,77 @@ pub fn remnant_size(paths: &[String]) -> i64 { total } +/// One installed Steam application discovered from an `appmanifest_*.acf`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SteamGame { + pub appid: String, + pub name: String, +} + +/// `acf_field` — value of `"key"\t\t"value"` in a Steam appmanifest. +fn acf_field(content: &str, key: &str) -> Option { + for line in content.lines() { + let line = line.trim(); + let Some(rest) = line.strip_prefix('"') else { + continue; + }; + let mut it = rest.splitn(2, '"'); + let k = it.next()?; + if k != key { + continue; + } + let after = it.next()?.trim_start().strip_prefix('"')?; + let end = after.find('"')?; + return Some(after[..end].to_string()); + } + None +} + +/// `steamGames` — Steam apps installed under `$XDG_DATA_HOME/Steam/steamapps`. +/// The manifests are read (never modified): uninstalling a game is delegated +/// to Steam rather than trashing Steam's own database. +pub(crate) fn steam_games_in(deps: &Deps) -> Vec { + let mut games = Vec::new(); + let steamapps = deps.data_home.join("Steam").join("steamapps"); + let Ok(entries) = std::fs::read_dir(&steamapps) else { + return games; + }; + let mut files: Vec = entries + .flatten() + .map(|e| e.path()) + .filter(|p| { + p.file_name() + .and_then(|f| f.to_str()) + .is_some_and(|f| f.starts_with("appmanifest_") && f.ends_with(".acf")) + }) + .collect(); + files.sort(); + for p in files { + let Ok(content) = std::fs::read_to_string(&p) else { + continue; + }; + let Some(appid) = acf_field(&content, "appid") else { + continue; + }; + let Some(name) = acf_field(&content, "name") else { + continue; + }; + games.push(SteamGame { appid, name }); + } + games +} + +/// Games whose display name matches `pkg_name` exactly (case-insensitive). +/// Substring matching is deliberately avoided: short package names (`git`, +/// `ui`) would otherwise match unrelated manifests. +pub(crate) fn steam_delegations(deps: &Deps, pkg_name: &str) -> Vec { + let needle = pkg_name.to_lowercase(); + steam_games_in(deps) + .into_iter() + .filter(|g| g.name.to_lowercase() == needle) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -169,4 +221,54 @@ mod tests { // /var/lib should be skipped, /tmp path doesn't exist so also 0. assert_eq!(remnant_size(&paths), 0); } + + // Finding 3: a Steam appmanifest whose *content* merely contains the + // package name as a substring (e.g. "digital" ~ "git") must never be + // treated as a removable remnant. + #[test] + fn steam_manifest_is_not_a_remnant() { + let tmp = tempdir("steam-remnant"); + let deps = deps_for_test(&tmp, Arc::new(FakeRunner::new())); + let steamapps = deps.data_home.join("Steam").join("steamapps"); + fs::create_dir_all(&steamapps).unwrap(); + fs::write( + steamapps.join("appmanifest_1.acf"), + "\"appid\"\t\t\"220\"\n\"name\"\t\t\"Half-Life\"\n\"path\"\t\t\"digital\"\n", + ) + .unwrap(); + let remnants = find_remnants_in(&deps, "git"); + assert!( + remnants.iter().all(|p| !p.ends_with(".acf")), + "Steam manifest leaked into remnants: {remnants:?}" + ); + fs::remove_dir_all(&tmp).ok(); + } + + // Finding 3: Steam games are discovered and matched exactly (never by + // substring) so uninstallation delegates to `steam steam://uninstall/`. + #[test] + fn steam_games_match_exactly() { + let tmp = tempdir("steam-games"); + let deps = deps_for_test(&tmp, Arc::new(FakeRunner::new())); + let steamapps = deps.data_home.join("Steam").join("steamapps"); + fs::create_dir_all(&steamapps).unwrap(); + fs::write( + steamapps.join("appmanifest_220.acf"), + "\"appid\"\t\t\"220\"\n\"name\"\t\t\"Half-Life\"\n", + ) + .unwrap(); + assert_eq!( + steam_games_in(&deps), + vec![SteamGame { + appid: "220".to_string(), + name: "Half-Life".to_string(), + }] + ); + assert_eq!(steam_delegations(&deps, "half-life").len(), 1); + assert!( + steam_delegations(&deps, "half").is_empty(), + "substring matched" + ); + fs::remove_dir_all(&tmp).ok(); + } } diff --git a/src/uninstall/remove.rs b/src/uninstall/remove.rs index 3f26dfa..513bd46 100644 --- a/src/uninstall/remove.rs +++ b/src/uninstall/remove.rs @@ -89,6 +89,9 @@ pub(crate) fn remove_selected_in( &pkg.name, if dry_run { "dry-run" } else { "success" }, ); + // Steam games are uninstalled by delegating to Steam — never + // by trashing the appmanifest (Steam's own database). + delegate_steam_in(deps, pkg, dry_run, out); } } results.push(result); @@ -176,6 +179,25 @@ pub(crate) fn remove_selected_in( results } +/// `delegateSteam` — hand a matched Steam game to Steam's own uninstaller +/// (`steam steam://uninstall/`) instead of trashing its appmanifest. +fn delegate_steam_in(deps: &Deps, pkg: &Package, dry_run: bool, out: &mut dyn Write) { + for game in super::remnants::steam_delegations(deps, &pkg.name) { + if dry_run { + let _ = writeln!( + out, + " [dry-run] would run: steam steam://uninstall/{}", + game.appid + ); + } else { + let _ = deps.runner.run(&CommandSpec::new( + "steam", + [format!("steam://uninstall/{}", game.appid)], + )); + } + } +} + /// `removePackage` — `sudo apt-get purge -y ` or `sudo snap remove /// `; the dry-run line is printed for `--dry-run` instead. Captured /// child output is relayed before the error is inspected, exactly like Go. @@ -464,4 +486,72 @@ mod tests { fs::remove_dir_all(&home).ok(); } + + // Finding 3: an exact Steam game name delegates to Steam's uninstaller + // and never trashes the appmanifest. + #[test] + fn steam_game_uninstall_delegates_to_steam() { + let home = tempdir("steam-delegate"); + let runner = Arc::new(FakeRunner::new()); + let deps = deps_for_test(&home, runner.clone()); + let steamapps = deps.data_home.join("Steam").join("steamapps"); + fs::create_dir_all(&steamapps).unwrap(); + fs::write( + steamapps.join("appmanifest_220.acf"), + "\"appid\"\t\t\"220\"\n\"name\"\t\t\"Half-Life\"\n", + ) + .unwrap(); + let pkg = Package { + name: "Half-Life".to_string(), + source: "apt".to_string(), + ..Default::default() + }; + + let mut out = Vec::new(); + let mut err_out = Vec::new(); + let results = remove_selected_in( + &deps, + std::slice::from_ref(&pkg), + std::slice::from_ref(&pkg), + true, + &mut out, + &mut err_out, + ); + assert!( + removal_errors(&results).is_none(), + "errors: {:?}", + removal_errors(&results) + ); + let text = String::from_utf8_lossy(&out); + assert!( + text.contains("steam steam://uninstall/220"), + "missing dry-run delegation: {text}" + ); + assert!( + steamapps.join("appmanifest_220.acf").exists(), + "manifest must never be trashed" + ); + + let runner2 = Arc::new(FakeRunner::new()); + let deps2 = deps_for_test(&home, runner2.clone()); + let mut out2 = Vec::new(); + let mut err2 = Vec::new(); + let results = remove_selected_in( + &deps2, + std::slice::from_ref(&pkg), + std::slice::from_ref(&pkg), + false, + &mut out2, + &mut err2, + ); + assert!(removal_errors(&results).is_none()); + let calls: Vec> = runner2.invocations().iter().map(argv).collect(); + assert!( + calls + .iter() + .any(|c| c == &["steam", "steam://uninstall/220"].map(String::from)), + "steam not invoked: {calls:?}" + ); + fs::remove_dir_all(&home).ok(); + } } diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index bc5e2d9..d70551e 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -412,3 +412,25 @@ fn uninstall_help_matches_golden() { assert_eq!(o.status.code(), Some(0)); assert_eq!(stdout(&o), include_str!("golden/help-uninstall.txt")); } + +/// Phase 2: `mu clean --external ` previews external-volume metadata and +/// mutates nothing on a dry run. +#[test] +fn clean_external_dry_run_previews() { + let dir = std::env::temp_dir().join(format!("mu-external-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join(".Trash-1000")).unwrap(); + std::fs::write(dir.join(".directory"), b"meta").unwrap(); + + let path = dir.to_string_lossy().into_owned(); + let o = mu(&["clean", "--dry-run", "--external", &path]); + assert_eq!(o.status.code(), Some(0), "stderr: {}", stderr(&o)); + let out = stdout(&o); + assert!(out.contains("External Volume"), "{out}"); + assert!( + dir.join(".directory").exists(), + "dry-run must not delete external metadata" + ); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/tests/golden/help-clean.txt b/tests/golden/help-clean.txt index 15e2554..3b264a0 100644 --- a/tests/golden/help-clean.txt +++ b/tests/golden/help-clean.txt @@ -17,6 +17,7 @@ Usage: Flags: --dry-run Preview actions without making changes + --external string Clean metadata on an external volume (mount path) -h, --help help for clean --include strings Opt-in categories (e.g. browser-cache) -y, --yes Skip confirmation prompt diff --git a/tests/golden/help-uninstall.txt b/tests/golden/help-uninstall.txt index 5e4c426..63fac34 100644 --- a/tests/golden/help-uninstall.txt +++ b/tests/golden/help-uninstall.txt @@ -13,6 +13,7 @@ Flags: -h, --help help for uninstall --list List installed packages and exit --permanent Bypass trash (rejected for safety) + -y, --yes Skip confirmation prompt Global Flags: --debug Enable verbose debug logging From 230f5ab0057fd78f970e4eff48c1296284047a61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ba=20R=E1=BB=8Di?= Date: Fri, 16 Oct 2026 16:34:54 +0700 Subject: [PATCH 07/17] fix(clean): review r1 findings 1,5,6,7 - external-volume: empty '.Trash-*' in place via FreeDesktop traversal instead of SafeDelete (which moved the trash into itself: EINVAL for the caller's uid, 0 bytes freed for others); '.directory' still SafeDeleted. Scan now reports dir_size errors (no 'let (sz, _)'). - add negative tests for --external root validation (/ , /etc, relative). - browser-cache: drop redundant is_protected_pwa_path checks (the delete plan never contains a protected path by construction). - flow: cache display labels at FlowModel construction so count_fn does not read the trash dir on every render tick. --- src/clean/flow.rs | 17 +++-- src/clean/mod.rs | 26 +++++++ src/clean/scan_browser.rs | 6 -- src/clean/targets.rs | 153 ++++++++++++++++++++++++++++++++------ 4 files changed, 170 insertions(+), 32 deletions(-) diff --git a/src/clean/flow.rs b/src/clean/flow.rs index 6371c91..fd85a90 100644 --- a/src/clean/flow.rs +++ b/src/clean/flow.rs @@ -136,6 +136,9 @@ pub(crate) enum RunDispatch { pub(crate) struct FlowModel { opts: Options, targets: Vec, + /// Display labels captured once at construction (`count_fn` may read the + /// filesystem) so the render path is free of filesystem reads. + display_labels: Vec, /// `state` — pub(crate) so the driver can cheaply test Scanning/Running. pub(crate) state: FlowState, /// Terminal width from `WindowSize`; default 80. @@ -175,9 +178,13 @@ struct Scanned { impl FlowModel { /// `newFlowModel`. pub(crate) fn new(opts: Options, targets: Vec) -> Self { + // Compute the display labels once, not on every render tick (finding 7 + // r1: `display_label` may read the filesystem for the trash count). + let display_labels = targets.iter().map(CleanTarget::display_label).collect(); Self { opts, targets, + display_labels, state: FlowState::Scanning, width: 80, spinner_idx: 0, @@ -464,8 +471,8 @@ impl FlowModel { let mut s = format!(" {} Scanning system", SPINNER_FRAMES[self.spinner_idx]); let done = self.results.len(); if done > 0 { - let label = if done < self.targets.len() { - format!(": {}", self.targets[done].display_label()) + let label = if done < self.display_labels.len() { + format!(": {}", self.display_labels[done]) } else { String::new() }; @@ -489,9 +496,9 @@ impl FlowModel { size_w = size_w.max(size::human_size(res.size).chars().count()); } let mut label_w = self - .targets + .display_labels .iter() - .map(|t| t.display_label().chars().count()) + .map(|l| l.chars().count()) .max() .unwrap_or(0); let max = self.width.saturating_sub(size_w + 6); @@ -514,7 +521,7 @@ impl FlowModel { ), ]; for (i, res) in self.results.iter().enumerate() { - let label = truncate(&self.targets[i].display_label(), label_w as isize); + let label = truncate(&self.display_labels[i], label_w as isize); lines.push(Line::raw(format!( " {label}{}{:>size_w$}", pad(&label), diff --git a/src/clean/mod.rs b/src/clean/mod.rs index b00c294..dad2aff 100644 --- a/src/clean/mod.rs +++ b/src/clean/mod.rs @@ -859,6 +859,32 @@ mod tests { fs::remove_dir_all(&tmp).ok(); } + // Finding 5 (r1): `--external` roots are validated fail-closed. + #[test] + fn external_volume_rejects_unsafe_roots() { + let tmp = tempdir("ext-bad"); + let deps = deps_for_test(&tmp, Arc::new(FakeRunner::new())); + for bad in ["/", "/etc", "relative/path"] { + let mut out = Vec::new(); + let err = run_in( + &Options { + dry_run: true, + external: Some(PathBuf::from(bad)), + ..Options::default() + }, + &deps, + &mut out, + ) + .expect_err("expected external root rejection"); + let text = err.to_string(); + assert!( + text.contains("unsafe cleanup root") || text.contains("must be absolute"), + "bad={bad} err={text}" + ); + } + fs::remove_dir_all(&tmp).ok(); + } + #[test] fn downloads_large_files_hint_test() { let tmp = tempdir("dlhint"); diff --git a/src/clean/scan_browser.rs b/src/clean/scan_browser.rs index eb56580..4a08119 100644 --- a/src/clean/scan_browser.rs +++ b/src/clean/scan_browser.rs @@ -212,9 +212,6 @@ pub(crate) fn browser_cache_target_in(deps: &Deps) -> CleanTarget { let mut scan_errors: Vec = Vec::new(); for p in browser_cache_paths_in(&scan_home) { for planned in browser_delete_plan(&p) { - if is_protected_pwa_path(&planned) { - continue; - } let (sz, err) = crate::size::dir_size(&planned); match err { None => total += sz as i64, @@ -237,9 +234,6 @@ pub(crate) fn browser_cache_target_in(deps: &Deps) -> CleanTarget { } let root = browser_cleanup_root(&exec_home, &p); for planned in browser_delete_plan(&p) { - if is_protected_pwa_path(&planned) { - continue; - } if let Err(e) = paths::validate_cleanup_candidate(&root, &planned) { delete_errors.push(e); continue; diff --git a/src/clean/targets.rs b/src/clean/targets.rs index 11f5712..73f267a 100644 --- a/src/clean/targets.rs +++ b/src/clean/targets.rs @@ -569,9 +569,30 @@ pub(crate) fn system_trash_target_in(deps: &Deps) -> CleanTarget { } } -/// `externalVolumeCandidates` — removable metadata directly under an external +/// The kind of an external-volume candidate. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum ExternalCandidate { + /// `/.Trash-` — a per-volume trash directory. It cannot be + /// trashed into itself; its contents are emptied (FreeDesktop traversal, + /// the same permanent semantics as `gio trash --empty`). + TrashDir, + /// `.directory` — plain metadata, recoverable via `SafeDelete`. + Metadata, +} + +fn classify_external(name: &str) -> Option { + if name.starts_with(".Trash-") { + Some(ExternalCandidate::TrashDir) + } else if name == ".directory" { + Some(ExternalCandidate::Metadata) + } else { + None + } +} + +/// `externalVolumeEntries` — removable metadata directly under an external /// mount: `.Trash-` directories and `.directory` files. -pub(crate) fn external_volume_candidates(mount: &Path) -> Vec { +pub(crate) fn external_volume_entries(mount: &Path) -> Vec<(PathBuf, ExternalCandidate)> { let mut found = Vec::new(); let Ok(entries) = std::fs::read_dir(mount) else { return found; @@ -581,16 +602,46 @@ pub(crate) fn external_volume_candidates(mount: &Path) -> Vec { for e in entries { let name = e.file_name(); let name = name.to_string_lossy(); - if name.starts_with(".Trash-") || name == ".directory" { - found.push(e.path()); + if let Some(kind) = classify_external(&name) { + found.push((e.path(), kind)); } } found } +/// `emptyTrashDir` — permanently remove the entries under a FreeDesktop trash +/// directory's `files/`, `info/`, and `expunged/` children. The trash +/// directory itself is never removed. Symlinks are unlinked, not followed. +fn empty_trash_dir(trash_root: &Path) -> Result<()> { + let mut errors: Vec = Vec::new(); + for sub in ["files", "info", "expunged"] { + let dir = trash_root.join(sub); + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for e in entries.flatten() { + let p = e.path(); + let res = match e.file_type() { + Ok(t) if t.is_dir() => std::fs::remove_dir_all(&p), + Ok(_) => std::fs::remove_file(&p), + Err(err) => Err(err), + }; + if let Err(err) = res { + errors.push(Error::Msg(format!("empty {}: {err}", p.display()))); + } + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(super::join_errors(&errors)) + } +} + /// `externalVolumeTarget` — clean target added by `mu clean --external `: -/// removes `.Trash-*` and `.directory` metadata on an external volume mount, -/// strictly through `SafeDelete` (never `rm`). +/// `.directory` metadata is trashed via `SafeDelete`; `.Trash-*` directories +/// are emptied in place (their contents are permanently removed, never trashed +/// into themselves). pub(crate) fn external_volume_target_in(deps: &Deps, mount: &Path) -> CleanTarget { let scan_mount = mount.to_path_buf(); let prev_mount = mount.to_path_buf(); @@ -607,17 +658,32 @@ pub(crate) fn external_volume_target_in(deps: &Deps, mount: &Path) -> CleanTarge opt_in: true, scan: Box::new(move || { let mut total = 0i64; - for p in external_volume_candidates(&scan_mount) { - let (sz, _) = size::dir_size(&p); - total += sz as i64; + let mut scan_errors: Vec = Vec::new(); + for (p, _) in external_volume_entries(&scan_mount) { + let (sz, err) = match std::fs::symlink_metadata(&p) { + Ok(m) if m.is_dir() => { + let (sz, err) = size::dir_size(&p); + (sz as i64, err) + } + Ok(m) => (m.len() as i64, None), + Err(e) => (0, Some(e.into())), + }; + match err { + None => total += sz, + Some(e) => scan_errors.push(e), + } + } + if scan_errors.is_empty() { + (total, None) + } else { + (total, Some(super::join_errors(&scan_errors))) } - (total, None) }), preview: Some(Box::new(move || { ( - external_volume_candidates(&prev_mount) + external_volume_entries(&prev_mount) .into_iter() - .map(|p| p.to_string_lossy().into_owned()) + .map(|(p, _)| p.to_string_lossy().into_owned()) .collect(), None, ) @@ -625,15 +691,44 @@ pub(crate) fn external_volume_target_in(deps: &Deps, mount: &Path) -> CleanTarge execute: Box::new(move |dry_run| { paths::validate_cleanup_root(&exec_mount)?; let mut delete_errors: Vec = Vec::new(); - for p in external_volume_candidates(&exec_mount) { + for (p, kind) in external_volume_entries(&exec_mount) { if let Err(e) = paths::validate_cleanup_candidate(&exec_mount, &p) { delete_errors.push(e); continue; } - if let Err(e) = - trash::safe_delete_with(&*exec_runner, &exec_cfg, &exec_deps, &p, dry_run) - { - delete_errors.push(e); + match kind { + ExternalCandidate::Metadata => { + if let Err(e) = trash::safe_delete_with( + &*exec_runner, + &exec_cfg, + &exec_deps, + &p, + dry_run, + ) { + delete_errors.push(e); + } + } + ExternalCandidate::TrashDir => { + if dry_run { + oplog::log_outcome("empty-trash", &p.to_string_lossy(), "dry-run"); + } else { + match empty_trash_dir(&p) { + Ok(()) => oplog::log_outcome( + "empty-trash", + &p.to_string_lossy(), + "success", + ), + Err(e) => { + oplog::log_outcome( + "empty-trash", + &p.to_string_lossy(), + "failure", + ); + delete_errors.push(e); + } + } + } + } } } if delete_errors.is_empty() { @@ -1086,13 +1181,17 @@ mod tests { fs::remove_dir_all(&tmp).ok(); } - // Finding 2: `--external ` target trashes `.Trash-*`/`.directory`. + // Finding 2 (review r1): `--external ` must empty `.Trash-*` + // in place (never trash the trash into itself) and SafeDelete `.directory`. #[test] fn external_volume_target_cleans_metadata() { let tmp = tempdir("external"); let mount = tmp.join("mnt"); - fs::create_dir_all(mount.join(".Trash-1000")).unwrap(); - fs::write(mount.join(".Trash-1000").join("f"), "x").unwrap(); + let trash = mount.join(".Trash-1000"); + fs::create_dir_all(trash.join("files")).unwrap(); + fs::create_dir_all(trash.join("info")).unwrap(); + fs::write(trash.join("files").join("junk"), "x").unwrap(); + fs::write(trash.join("info").join("junk.trashinfo"), "meta").unwrap(); fs::write(mount.join(".directory"), "meta").unwrap(); let runner = Arc::new(FakeRunner::new()); let deps = deps_for_test(&tmp, runner); @@ -1103,10 +1202,22 @@ mod tests { (target.execute)(true).expect("dry-run execute"); assert!(mount.join(".directory").exists(), "dry-run deleted a file"); + assert!( + trash.join("files").join("junk").exists(), + "dry-run emptied trash" + ); (target.execute)(false).expect("real execute"); assert!(!mount.join(".directory").exists(), ".directory not removed"); - assert!(!mount.join(".Trash-1000").exists(), ".Trash-* not removed"); + assert!(trash.exists(), "trash dir itself must be kept"); + assert!( + !trash.join("files").join("junk").exists(), + "trash files/ contents not emptied" + ); + assert!( + !trash.join("info").join("junk.trashinfo").exists(), + "trash info/ contents not emptied" + ); fs::remove_dir_all(&tmp).ok(); } } From 5ef37f780b4956bcc0122ad976969c8cc8d35900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ba=20R=E1=BB=8Di?= Date: Fri, 16 Oct 2026 16:35:00 +0700 Subject: [PATCH 08/17] fix(uninstall): review r1 findings 2,3,4 - Steam games are now first-class candidates reached by name (mu uninstall ""), printed in the plan and gated like any package; remove_package handles source=steam via steam://uninstall/ with a look_path check and error propagation. Removed the hidden apt/snap side-effect that fired on a name collision. - the direct-CLI confirmation gate takes an injected ConfirmFn instead of reading the real process stdin, so tests never block on a TTY and the y/N/EOF answers are covered. - dry-run header printed before the plan it introduces (output order). --- src/uninstall/mod.rs | 186 ++++++++++++++++++++++++++++++++++------ src/uninstall/remove.rs | 54 ++++++------ 2 files changed, 188 insertions(+), 52 deletions(-) diff --git a/src/uninstall/mod.rs b/src/uninstall/mod.rs index 2a2f4d8..b84de18 100644 --- a/src/uninstall/mod.rs +++ b/src/uninstall/mod.rs @@ -219,7 +219,7 @@ pub(crate) fn run_in(opts: &Options, deps: &Deps, err_out: &mut dyn Write) -> i3 if !opts.packages.is_empty() { let stdout = std::io::stdout(); let mut out = stdout.lock(); - return run_cli_uninstall(opts, deps, &mut out, err_out); + return run_cli_uninstall(opts, deps, &mut out, err_out, &mut confirm_stdin); } // Go: `tea.NewProgram(m, tea.WithAltScreen()).Run()` is the only path. @@ -286,11 +286,45 @@ pub(crate) fn finish( let _ = writeln!(out, "\nUninstall complete."); 0 } +/// Outcome of the direct-CLI confirmation gate. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum ConfirmOutcome { + Confirmed, + Declined, + NonInteractive, +} + +/// Confirmation source for the direct-CLI removal gate — injected so tests +/// never read the real process stdin. +pub(crate) type ConfirmFn<'a> = &'a mut dyn FnMut(usize) -> ConfirmOutcome; + +/// Production confirmation: prompt on a TTY, fail closed otherwise. +fn confirm_stdin(count: usize) -> ConfirmOutcome { + if !std::io::stdin().is_terminal() { + return ConfirmOutcome::NonInteractive; + } + eprint!("Proceed with removal of {count} package(s)? [y/N] "); + let _ = std::io::stderr().flush(); + let mut line = String::new(); + match std::io::stdin().lock().read_line(&mut line) { + Ok(_) => { + let answer = line.trim().to_ascii_lowercase(); + if answer == "y" || answer == "yes" { + ConfirmOutcome::Confirmed + } else { + ConfirmOutcome::Declined + } + } + Err(_) => ConfirmOutcome::Declined, + } +} + fn run_cli_uninstall( opts: &Options, deps: &Deps, out: &mut dyn Write, err_out: &mut dyn Write, + confirm: ConfirmFn, ) -> i32 { let (all_installed, err) = discover(&*deps.runner); if let Some(e) = err @@ -308,8 +342,29 @@ fn run_cli_uninstall( let by_name: Vec<&Package> = all_installed.iter().filter(|p| p.name == *target).collect(); match by_name.as_slice() { [] => { - let _ = writeln!(err_out, "error: package '{target}' not found"); - return 1; + // No package by that name — a Steam game of the same name is a + // first-class candidate that delegates to Steam's uninstaller. + match remnants::steam_delegations(deps, target).as_slice() { + [game] => selected.push(Package { + name: target.clone(), + version: game.name.clone(), + source: "steam".to_string(), + ..Default::default() + }), + [] => { + let _ = writeln!(err_out, "error: package '{target}' not found"); + return 1; + } + many => { + let names: Vec<&str> = many.iter().map(|g| g.name.as_str()).collect(); + let _ = writeln!( + err_out, + "error: '{target}' is ambiguous; matches {} Steam game(s)", + names.join(", ") + ); + return 1; + } + } } [pkg] => selected.push(with_remnants(deps, pkg)), many => { @@ -327,6 +382,10 @@ fn run_cli_uninstall( } } + // Dry-run header comes before the plan it introduces. + if opts.dry_run { + let _ = writeln!(out, "Dry run: previewing package removal plan."); + } // Always print the removal plan so the action is previewable before any // package is touched. for pkg in &selected { @@ -346,7 +405,6 @@ fn run_cli_uninstall( } if opts.dry_run { - let _ = writeln!(out, "Dry run: previewing package removal plan."); let results = remove_selected_in(deps, &selected, &all_installed, true, out, err_out); if let Some(e) = removal_errors(&results) { let _ = writeln!(err_out, "error: {e}"); @@ -357,29 +415,20 @@ fn run_cli_uninstall( // Fail-closed confirmation gate (finding: unprompted `apt-get purge -y`). if !opts.auto_yes { - if std::io::stdin().is_terminal() { - let _ = write!( - err_out, - "Proceed with removal of {} package(s)? [y/N] ", - selected.len() - ); - let _ = err_out.flush(); - let mut line = String::new(); - let confirmed = std::io::stdin().lock().read_line(&mut line).is_ok_and(|_| { - let t = line.trim().to_ascii_lowercase(); - t == "y" || t == "yes" - }); - if !confirmed { + match confirm(selected.len()) { + ConfirmOutcome::Confirmed => {} + ConfirmOutcome::Declined => { + let _ = writeln!(out, "Aborted."); + return 0; + } + ConfirmOutcome::NonInteractive => { + let _ = writeln!( + out, + "Non-interactive: pass --yes to confirm, or --dry-run to preview." + ); let _ = writeln!(out, "Aborted."); return 0; } - } else { - let _ = writeln!( - out, - "Non-interactive: pass --yes to confirm, or --dry-run to preview." - ); - let _ = writeln!(out, "Aborted."); - return 0; } } @@ -685,8 +734,9 @@ mod tests { }) } - // Finding 1: direct CLI removal must not purge without `--yes` - // (fail-closed), mirroring clean's non-interactive gate. + // Finding 1 (r1): direct CLI removal must not purge without `--yes` + // (fail-closed), and the gate's input is injected so tests never read the + // real process stdin. #[test] fn run_cli_uninstall_requires_yes_gate() { let root = tempdir("gate"); @@ -703,11 +753,12 @@ mod tests { &deps, &mut out, &mut err_out, + &mut |_| ConfirmOutcome::NonInteractive, ); assert_eq!(code, 0, "stderr: {}", String::from_utf8_lossy(&err_out)); assert!( - String::from_utf8_lossy(&out).contains("Aborted."), - "expected abort note, got {:?}", + String::from_utf8_lossy(&out).contains("Non-interactive: pass --yes"), + "expected non-interactive note, got {:?}", String::from_utf8_lossy(&out) ); assert!( @@ -718,6 +769,32 @@ mod tests { fs::remove_dir_all(&root).ok(); } + // Finding 3 (r1): an explicit TTY "no" answer aborts without touching + // packages, and the input is injected. + #[test] + fn run_cli_uninstall_declined_gate_aborts() { + let root = tempdir("declined"); + let runner = runner_with_pkg("install ok installed\t1024\ttest-pkg\t1.0.0\n"); + let deps = deps_for_test(&root, runner.clone()); + let mut out = Vec::new(); + let mut err_out = Vec::new(); + let code = run_cli_uninstall( + &Options { + packages: vec!["test-pkg".to_string()], + auto_yes: false, + ..Default::default() + }, + &deps, + &mut out, + &mut err_out, + &mut |_| ConfirmOutcome::Declined, + ); + assert_eq!(code, 0); + assert!(String::from_utf8_lossy(&out).contains("Aborted.")); + assert!(!purge_invoked(&runner, "test-pkg")); + fs::remove_dir_all(&root).ok(); + } + #[test] fn run_cli_uninstall_with_yes_executes() { let root = tempdir("yes"); @@ -734,6 +811,7 @@ mod tests { &deps, &mut out, &mut err_out, + &mut |_| ConfirmOutcome::NonInteractive, ); assert_eq!(code, 0, "stderr: {}", String::from_utf8_lossy(&err_out)); assert!(purge_invoked(&runner, "test-pkg"), "expected purge call"); @@ -772,6 +850,7 @@ mod tests { &deps, &mut out, &mut err_out, + &mut |_| ConfirmOutcome::NonInteractive, ); assert_eq!(code, 1); let text = String::from_utf8_lossy(&err_out); @@ -791,8 +870,59 @@ mod tests { &deps, &mut out, &mut err_out, + &mut |_| ConfirmOutcome::NonInteractive, + ); + assert_eq!(code, 0, "stderr: {}", String::from_utf8_lossy(&err_out)); + fs::remove_dir_all(&root).ok(); + } + + // Finding 2 (r1): a Steam game is a first-class candidate reached by name; + // a non-Steam package of the same name must NOT trigger Steam. + #[test] + fn run_cli_uninstall_steam_candidate_is_explicit() { + let root = tempdir("steam-cand"); + let runner = runner_with_pkg("install ok installed\t1024\tother-pkg\t1.0\n"); + runner.set_look_path("steam", "/usr/bin/steam"); + let deps = deps_for_test(&root, runner.clone()); + let steamapps = deps.data_home.join("Steam").join("steamapps"); + fs::create_dir_all(&steamapps).unwrap(); + fs::write( + steamapps.join("appmanifest_220.acf"), + "\"appid\"\t\t\"220\"\n\"name\"\t\t\"Half-Life\"\n", + ) + .unwrap(); + + // `mu uninstall "Half-Life"` resolves to the steam candidate, prints a + // plan line, and on --yes delegates to Steam. + let mut out = Vec::new(); + let mut err_out = Vec::new(); + let code = run_cli_uninstall( + &Options { + packages: vec!["Half-Life".to_string()], + auto_yes: true, + ..Default::default() + }, + &deps, + &mut out, + &mut err_out, + &mut |_| ConfirmOutcome::NonInteractive, ); assert_eq!(code, 0, "stderr: {}", String::from_utf8_lossy(&err_out)); + let text = String::from_utf8_lossy(&out); + assert!( + text.contains("steam:Half-Life"), + "steam candidate missing from plan: {text}" + ); + let calls: Vec<_> = runner.invocations(); + assert!( + calls.iter().any(|c| c.program == "steam" + && c.args == [std::ffi::OsString::from("steam://uninstall/220")]), + "steam not delegated: {calls:?}" + ); + assert!( + !purge_invoked(&runner, "Half-Life"), + "steam candidate must not purge an apt package" + ); fs::remove_dir_all(&root).ok(); } } diff --git a/src/uninstall/remove.rs b/src/uninstall/remove.rs index 513bd46..859be64 100644 --- a/src/uninstall/remove.rs +++ b/src/uninstall/remove.rs @@ -89,9 +89,6 @@ pub(crate) fn remove_selected_in( &pkg.name, if dry_run { "dry-run" } else { "success" }, ); - // Steam games are uninstalled by delegating to Steam — never - // by trashing the appmanifest (Steam's own database). - delegate_steam_in(deps, pkg, dry_run, out); } } results.push(result); @@ -179,28 +176,11 @@ pub(crate) fn remove_selected_in( results } -/// `delegateSteam` — hand a matched Steam game to Steam's own uninstaller -/// (`steam steam://uninstall/`) instead of trashing its appmanifest. -fn delegate_steam_in(deps: &Deps, pkg: &Package, dry_run: bool, out: &mut dyn Write) { - for game in super::remnants::steam_delegations(deps, &pkg.name) { - if dry_run { - let _ = writeln!( - out, - " [dry-run] would run: steam steam://uninstall/{}", - game.appid - ); - } else { - let _ = deps.runner.run(&CommandSpec::new( - "steam", - [format!("steam://uninstall/{}", game.appid)], - )); - } - } -} - /// `removePackage` — `sudo apt-get purge -y ` or `sudo snap remove /// `; the dry-run line is printed for `--dry-run` instead. Captured /// child output is relayed before the error is inspected, exactly like Go. +/// A `steam` source delegates to Steam's own uninstaller rather than touching +/// the appmanifest. pub(crate) fn remove_package( deps: &Deps, pkg: &Package, @@ -208,6 +188,31 @@ pub(crate) fn remove_package( out: &mut dyn Write, err_out: &mut dyn Write, ) -> Result<()> { + if pkg.source == "steam" { + let games = super::remnants::steam_delegations(deps, &pkg.name); + let Some(game) = games.first() else { + return msg(format!("steam game '{}' not found", pkg.name)); + }; + if dry_run { + let _ = writeln!( + out, + " [dry-run] would run: steam steam://uninstall/{}", + game.appid + ); + return Ok(()); + } + if deps.runner.look_path("steam").is_err() { + return msg("steam not found: cannot uninstall Steam games".to_string()); + } + return deps + .runner + .run(&CommandSpec::new( + "steam", + [format!("steam://uninstall/{}", game.appid)], + )) + .map(|_| ()) + .map_err(|e| Error::Msg(format!("steam uninstall {}: {e}", game.appid))); + } let (prog, args) = match pkg.source.as_str() { "apt" => ("sudo", vec!["apt-get", "purge", "-y", pkg.name.as_str()]), "snap" => ("sudo", vec!["snap", "remove", pkg.name.as_str()]), @@ -487,7 +492,7 @@ mod tests { fs::remove_dir_all(&home).ok(); } - // Finding 3: an exact Steam game name delegates to Steam's uninstaller + // Finding 3 (r1): a real Steam source delegates to Steam's own uninstaller // and never trashes the appmanifest. #[test] fn steam_game_uninstall_delegates_to_steam() { @@ -503,7 +508,7 @@ mod tests { .unwrap(); let pkg = Package { name: "Half-Life".to_string(), - source: "apt".to_string(), + source: "steam".to_string(), ..Default::default() }; @@ -533,6 +538,7 @@ mod tests { ); let runner2 = Arc::new(FakeRunner::new()); + runner2.set_look_path("steam", "/usr/bin/steam"); let deps2 = deps_for_test(&home, runner2.clone()); let mut out2 = Vec::new(); let mut err2 = Vec::new(); From a4b78ad8246e89df0151ebd87e8bc38ee5b76d77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ba=20R=E1=BB=8Di?= Date: Fri, 16 Oct 2026 16:41:45 +0700 Subject: [PATCH 09/17] fix(clean): refuse symlinked trash subdirs on external volumes (r2 f1) empty_trash_dir read_dir'd /.Trash-/files directly, so a symlinked files/ (or info/, expunged/) pointing outside the mount made --external permanently delete outside the volume, bypassing SafeDelete. lstat each subdir and refuse a symlink/non-directory before read_dir; entries inside are still unlinked without following symlinks. Test: external_volume_refuses_symlinked_trash_subdir. --- src/clean/targets.rs | 60 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/src/clean/targets.rs b/src/clean/targets.rs index 73f267a..e4daa80 100644 --- a/src/clean/targets.rs +++ b/src/clean/targets.rs @@ -610,17 +610,42 @@ pub(crate) fn external_volume_entries(mount: &Path) -> Vec<(PathBuf, ExternalCan } /// `emptyTrashDir` — permanently remove the entries under a FreeDesktop trash -/// directory's `files/`, `info/`, and `expunged/` children. The trash -/// directory itself is never removed. Symlinks are unlinked, not followed. +/// directory's `files/`, `info/`, and `expunged/` children. The trash directory +/// itself is never removed. +/// +/// The volume is untrusted, so each subdirectory is lstat'd first: a symlinked +/// (or non-directory) `files/`/`info/`/`expunged/` is refused, never traversed. +/// `read_dir` would otherwise follow `/.Trash-/files -> $HOME` and +/// permanently delete everything outside the volume. Entries *inside* a real +/// subdir are unlinked individually (symlinks are removed, not followed). fn empty_trash_dir(trash_root: &Path) -> Result<()> { let mut errors: Vec = Vec::new(); for sub in ["files", "info", "expunged"] { let dir = trash_root.join(sub); + match std::fs::symlink_metadata(&dir) { + Ok(m) if m.file_type().is_symlink() => { + errors.push(Error::Msg(format!( + "refusing symlinked trash subdir {}", + dir.display() + ))); + continue; + } + // A non-directory where a subdir should be is not traversed. + Ok(m) if !m.is_dir() => continue, + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => { + errors.push(Error::Msg(format!("inspect {}: {e}", dir.display()))); + continue; + } + } + // `read_dir` on `dir` is now safe: `dir` is a real directory (lstat). let Ok(entries) = std::fs::read_dir(&dir) else { continue; }; for e in entries.flatten() { let p = e.path(); + // DirEntry::file_type never follows symlinks. let res = match e.file_type() { Ok(t) if t.is_dir() => std::fs::remove_dir_all(&p), Ok(_) => std::fs::remove_file(&p), @@ -1220,4 +1245,35 @@ mod tests { ); fs::remove_dir_all(&tmp).ok(); } + + // Finding 1 (review r2): a symlinked `files/`/`info/`/`expunged/` on an + // untrusted external volume must never be followed out of the mount. + #[test] + fn external_volume_refuses_symlinked_trash_subdir() { + for sub in ["files", "info", "expunged"] { + let tmp = tempdir("ext-symlink"); + let mount = tmp.join("mnt"); + let trash = mount.join(".Trash-1000"); + fs::create_dir_all(&trash).unwrap(); + // A directory OUTSIDE the mount that must never be touched. + let outside = tmp.join("outside"); + fs::create_dir_all(&outside).unwrap(); + fs::write(outside.join("victim"), "keep").unwrap(); + // /.Trash-1000/ -> ../outside (absolute symlink) + std::os::unix::fs::symlink(&outside, trash.join(sub)).unwrap(); + + let deps = deps_for_test(&tmp, Arc::new(FakeRunner::new())); + let target = external_volume_target_in(&deps, &mount); + let err = (target.execute)(false).expect_err("expected refusal"); + assert!( + err.to_string().contains("symlinked trash subdir"), + "sub={sub} unexpected error: {err}" + ); + assert!( + outside.join("victim").exists(), + "sub={sub}: file outside the mount was deleted" + ); + fs::remove_dir_all(&tmp).ok(); + } + } } From 6a0f1dba9bb379e7b533fa80140cd57b3ccbb552 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ba=20R=E1=BB=8Di?= Date: Fri, 16 Oct 2026 17:12:27 +0700 Subject: [PATCH 10/17] fix(clean,optimize): resolve Phase 3 review findings 1-6 - orphaned-app-data: fail closed when package discovery fails, and add a per-path preview with the review's named user-data dirs protected (f1, f2). - podman: report reclaimable size from `podman system df` and preview the stopped containers / dangling images prune would remove (f3). - optimize state step: resolve XDG_STATE_HOME fail-closed, validate the cleanup root and every candidate, surface SafeDelete errors, and never touch live state (nvim/wireplumber/mpv, $HISTFILE) (f4). - orphaned-services: recurse *.wants/ under the configured config home, report the real symlink size, and relabel to user-scope (f5). - dns/systemd: run the polkit-gated system scope with sudo (f6). - align the Phase 3 spec text and add a test per fixed behaviour. --- docs/plans/active/mole-parity.md | 18 +- src/clean/scan_docker.rs | 5 +- src/clean/targets.rs | 506 ++++++++++++++++++++++++++----- src/optimize.rs | 349 ++++++++++++++++++--- src/xdg.rs | 14 + tests/cli_integration.rs | 6 +- 6 files changed, 769 insertions(+), 129 deletions(-) diff --git a/docs/plans/active/mole-parity.md b/docs/plans/active/mole-parity.md index e4f1da5..aa55870 100644 --- a/docs/plans/active/mole-parity.md +++ b/docs/plans/active/mole-parity.md @@ -16,7 +16,7 @@ updated: 2026-10-14 - `make test` passes with zero regressions across all unit and integration suites (312+ baseline tests preserved). - Interactive Disk Analysis (Phase 1): `mu analyze` provides an interactive ncdu-like Ratatui disk usage explorer with `--json`, `--dry-run` immutability proof, and in-app safe trash deletion. - Clean & Uninstall enhancements (Phase 2): Snap/Flatpak browser caches detected, `system-trash` clean target reclaims space safely with size and item count preview via `gio trash --empty` (failing closed without `--yes` in headless mode), preview ledger exported to `$XDG_CONFIG_HOME/mu/clean-list.txt`, `mu uninstall ` executes non-interactively from CLI, `mu uninstall --list` outputs installed packages, and `--permanent` is explicitly rejected with a helpful safety error. - - Optimize & Clean extensions (Phase 3): `mu optimize` expands from 3 to 9 safe maintenance units (DNS cache flush, broken package config repair, failed systemd unit reset, unused Flatpak runtime cleanup, SQLite vacuum, stale state cleanup, broken autostart audit, broken services audit, and home permissions audit) with `--skip` support and exact shell command display in `--dry-run`; optional clean targets for developer CLI caches (`--include=dev-cache`), Podman (`--include=podman`), and orphaned system services (`--include=orphaned-services`). The dangerous route/neighbor table flush is dropped and marked N/A as it severs live SSH connections. + - Optimize & Clean extensions (Phase 3): `mu optimize` expands from 3 to 9 safe maintenance units (DNS cache flush, broken package config repair, failed systemd unit reset, unused Flatpak runtime cleanup, SQLite vacuum, stale state cleanup, broken autostart audit, broken services audit, and home permissions audit) with `--skip` support and exact shell command display in `--dry-run`; optional clean targets for developer CLI caches (`--include=dev-cache`), Podman (`--include=podman`), and orphaned user services (`--include=orphaned-services`). The dangerous route/neighbor table flush is dropped and marked N/A as it severs live SSH connections. - History, Purge & Installer (Phase 4): `mu history` inspects `$XDG_DATA_HOME/mu/operations.log` in table and `--json` format; `mu purge` safely cleans heavy project build artifacts (`node_modules`, `target`, `dist`, `.venv`) with `--paths`, `--dry-run`, and `--yes`; `mu installer` finds and cleans large package installers in `~/Downloads`. - Advanced Status Monitoring (Phase 5): `mu status` supports continuous NDJSON streaming via `--watch [--interval 1s]` and surfaces per-core CPU, disk I/O, battery, and top process stats. - Management & Biometrics (Phase 6): `mu sudo` provides PAM fingerprint configuration (`enable`, `disable`, `status`) mirroring `mo touchid`; `mu update` checks GitHub releases and self-updates verified binaries; `mu remove` safely uninstalls `mu`; `mu completion --install` auto-installs shell scripts. @@ -103,11 +103,11 @@ The following table catalogs every user-facing feature from upstream Mole (`tw93 | **Uninstall: Sensitive Data Review** | `lib/uninstall/review.sh:1-50` | **missing**: `src/uninstall/remove.rs` deletes all remnants together | In interactive TUI, provide a sub-item selection screen allowing user to uncheck user data/config directories while purging package binaries. | | **Uninstall: Steam Game Detection** | `lib/uninstall/steam.sh` | **missing**: no Steam handling in `src/uninstall/` | Detect Steam app manifests under `~/.local/share/Steam/steamapps/appmanifest_*.acf` and provide uninstallation delegation via `steam steam://uninstall/`. | | **Optimize: Whitelist TUI Editor** | `bin/optimize.sh`, `lib/manage/whitelist.sh:32` | **missing**: `src/config.rs:49` reads `optimize_skip` from TOML | Provide interactive TUI screen to toggle `optimize_skip` steps in `~/.config/mu/config.toml`. | -| **Optimize: Flush DNS Cache** | `lib/optimize/catalog.sh:29, 37` | **missing**: not in `src/optimize.rs:92-121` | Implement step `dns`: execute `resolvectl flush-caches` (or fallback `systemd-resolve --flush-caches`). | +| **Optimize: Flush DNS Cache** | `lib/optimize/catalog.sh:29, 37` | **missing**: not in `src/optimize.rs:92-121` | Implement step `dns`: execute `sudo resolvectl flush-caches` (or fallback `sudo systemd-resolve --flush-caches`); `flush-caches` is polkit-gated, so the system step runs with sudo (Conflict 2). | | **Optimize: Network Stack Refresh** | `lib/optimize/catalog.sh:45` | **N/A on Linux**: unsafe on Ubuntu | **DROPPED:** Route cache is obsolete on Linux >= 3.6; neighbor table flush (`ip neigh flush all`) drops active SSH/network sessions. Moved to N/A section. | | **Optimize: Package Repair** | `lib/optimize/catalog.sh:35` | **missing**: not in `src/optimize.rs` | Implement step `packages`: execute `sudo dpkg --configure -a` and `sudo apt-get install -f -y`. | | **Optimize: SQLite Database VACUUM** | `lib/optimize/catalog.sh:39` | **missing**: not in `src/optimize.rs` | Implement step `sqlite`: scan Firefox/Chrome/Thunderbird profiles and run `sqlite3 "VACUUM;"` when browser processes are not running. | -| **Optimize: Systemd Failed Units** | Linux equivalent of launchd repair | **missing**: not in `src/optimize.rs` | Implement step `systemd`: execute `systemctl reset-failed` and `systemctl --user reset-failed`. | +| **Optimize: Systemd Failed Units** | Linux equivalent of launchd repair | **missing**: not in `src/optimize.rs` | Implement step `systemd`: execute `sudo systemctl reset-failed` (system scope is polkit-gated) and `systemctl --user reset-failed` (Conflict 2). | | **Optimize: Flatpak Unused Runtimes** | Linux package orphan cleanup | **missing**: not in `src/optimize.rs` | Implement step `flatpak`: execute `flatpak uninstall --unused -y` if flatpak binary is present. | | **Optimize: Stale App Saved State** | `lib/optimize/catalog.sh:34` | **missing**: not in `src/optimize.rs` | Implement step `state`: clean files older than 30 days in `~/.local/state/` matching known transient state patterns. | | **Optimize: User Perms Repair** | `lib/optimize/catalog.sh:47` | **missing**: not in `src/optimize.rs` | Implement step `permissions`: audit files in `$HOME` owned by root or with broken permissions. | @@ -273,9 +273,9 @@ graph TD - `apt`: `sudo apt-get update && sudo apt-get autoremove --purge -y` - `journal`: `sudo journalctl --vacuum-size=500M` - `caches`: `sudo update-mime-database /usr/share/mime && fc-cache -f` - - `dns`: Flush DNS cache via `resolvectl flush-caches` (fallback `systemd-resolve --flush-caches`). + - `dns`: Flush DNS cache via `sudo resolvectl flush-caches` (fallback `sudo systemd-resolve --flush-caches`). - `packages`: Repair interrupted package installs via `sudo dpkg --configure -a && sudo apt-get install -f -y`. - - `systemd`: Reset failed systemd units via `systemctl reset-failed && systemctl --user reset-failed`. + - `systemd`: Reset failed systemd units via `sudo systemctl reset-failed && systemctl --user reset-failed`. - `flatpak`: Clean unused Flatpak runtimes via `flatpak uninstall --unused -y` (skipped if Flatpak absent). - `sqlite`: Vacuum closed application databases (Firefox, Chrome, Thunderbird sqlite databases). - `state`: Clean stale files older than 30 days in `~/.local/state/`. @@ -287,9 +287,9 @@ graph TD - `apt`: `[dry-run] sudo apt-get update && sudo apt-get autoremove --purge -y` - `journal`: `[dry-run] sudo journalctl --vacuum-size=500M` - `caches`: `[dry-run] sudo update-mime-database /usr/share/mime && fc-cache -f` - - `dns`: `[dry-run] resolvectl flush-caches` + - `dns`: `[dry-run] sudo resolvectl flush-caches` - `packages`: `[dry-run] sudo dpkg --configure -a && sudo apt-get install -f -y` - - `systemd`: `[dry-run] systemctl reset-failed && systemctl --user reset-failed` + - `systemd`: `[dry-run] sudo systemctl reset-failed && systemctl --user reset-failed` - `flatpak`: `[dry-run] flatpak uninstall --unused -y` - `sqlite`: `[dry-run] sqlite3 "VACUUM;"` - `state`: `[dry-run] find ~/.local/state -type f -mtime +30 -delete` @@ -298,7 +298,7 @@ graph TD - `dev-cache` (`--include=dev-cache`): clean `~/.cache/pip`, `~/.npm/_cacache`, `~/.cargo/registry/cache`, `~/.cache/go-build`. - `podman` (`--include=podman`): run `podman system prune -f` when Podman socket or binary is detected. - `orphaned-app-data` (`--include=orphaned-app-data`): scan `$XDG_DATA_HOME` and `$XDG_CONFIG_HOME` for uninstalled packages. - - `orphaned-services` (`--include=orphaned-services`): clean broken systemd user and system unit symlinks. + - `orphaned-services` (`--include=orphaned-services`): clean broken systemd **user** unit symlinks, recursing `*.wants/` enablement dirs; system-scope units are audit-only (`optimize services`) since they cannot go through SafeDelete. - **Files Likely Touched:** - `src/optimize.rs` - `src/clean/targets.rs` @@ -312,7 +312,7 @@ graph TD - Network route/neighbor table flush is excluded. - **Concrete Acceptance Tests:** 1. `cargo test optimize::` -> `test result: ok; 0 failed` - 2. `cargo run --release -- optimize --dry-run` -> exit 0; output lists registered steps showing the exact shell command string for each step (e.g. `[dry-run] sudo journalctl --vacuum-size=500M`, `[dry-run] resolvectl flush-caches`). + 2. `cargo run --release -- optimize --dry-run` -> exit 0; output lists registered steps showing the exact shell command string for each step (e.g. `[dry-run] sudo journalctl --vacuum-size=500M`, `[dry-run] sudo resolvectl flush-caches`). 3. `cargo run --release -- optimize --dry-run --skip=apt,dns,packages` -> exit 0; output explicitly indicates `[skip]` for `apt`, `dns`, and `packages`. 4. Sentinel immutability test: ```bash diff --git a/src/clean/scan_docker.rs b/src/clean/scan_docker.rs index 86dd1c2..f747d5e 100644 --- a/src/clean/scan_docker.rs +++ b/src/clean/scan_docker.rs @@ -45,8 +45,9 @@ fn parse_docker_build_cache_size(json_lines: &str) -> i64 { /// `parseDockerSize` — Docker size strings like "1.2GB", "512MB", "0B" to /// bytes. Go's `fmt.Sscanf(s, "%f%s", ...)` result is used unconditionally — -/// a failed scan leaves `0`/`""`, which no switch arm matches. -fn parse_docker_size(s: &str) -> i64 { +/// a failed scan leaves `0`/`""`, which no switch arm matches. Shared with the +/// Podman target (`podman system df` reports the same size strings). +pub(crate) fn parse_docker_size(s: &str) -> i64 { let s = s.trim(); if s.is_empty() || s == "0B" { return 0; diff --git a/src/clean/targets.rs b/src/clean/targets.rs index 6693a47..d5016c4 100644 --- a/src/clean/targets.rs +++ b/src/clean/targets.rs @@ -913,8 +913,33 @@ fn dev_cache_root(home: &Path, p: &Path) -> PathBuf { p.parent().unwrap_or(home).to_path_buf() } +/// One line of `podman system df --format '{{json .}}'`. +#[derive(serde::Deserialize)] +struct PodmanDfLine { + #[serde(default, rename = "Reclaimable")] + reclaimable: String, +} + +/// `parsePodmanReclaimable` — sum the `Reclaimable` column across every +/// `podman system df` row; non-JSON and unparseable rows are skipped. +fn parse_podman_reclaimable(json_lines: &str) -> i64 { + let mut total = 0i64; + for line in json_lines.split('\n') { + let line = line.trim(); + if line.is_empty() { + continue; + } + let Ok(entry) = serde_json::from_str::(line) else { + continue; + }; + total += super::scan_docker::parse_docker_size(&entry.reclaimable); + } + total +} + pub(crate) fn podman_target_in(deps: &Deps) -> CleanTarget { - let runner = Arc::clone(&deps.runner); + let scan_runner = Arc::clone(&deps.runner); + let preview_runner = Arc::clone(&deps.runner); let exec_runner = Arc::clone(&deps.runner); CleanTarget { @@ -923,12 +948,61 @@ pub(crate) fn podman_target_in(deps: &Deps) -> CleanTarget { requires_sudo: false, opt_in: true, scan: Box::new(move || { - if runner.look_path("podman").is_err() { + if scan_runner.look_path("podman").is_err() { return (0, None); } - (0, None) + match scan_runner.run(&CommandSpec::new( + "podman", + ["system", "df", "--format", "{{json .}}"], + )) { + Err(e) => (0, Some(Error::Msg(format!("podman system df: {e}")))), + Ok(out) => (parse_podman_reclaimable(&out.stdout_lossy()), None), + } }), - preview: None, + // `podman system prune -f` is irreversible and cannot route through + // SafeDelete, so list exactly what it would remove before confirming. + preview: Some(Box::new(move || { + if preview_runner.look_path("podman").is_err() { + return (Vec::new(), None); + } + let mut items = Vec::new(); + if let Ok(out) = preview_runner.run(&CommandSpec::new( + "podman", + [ + "container", + "ls", + "-a", + "--filter", + "status=exited", + "--format", + "{{.Names}}", + ], + )) { + for name in out.stdout_lossy().lines().map(str::trim) { + if !name.is_empty() { + items.push(format!("stopped container: {name}")); + } + } + } + if let Ok(out) = preview_runner.run(&CommandSpec::new( + "podman", + [ + "image", + "ls", + "--filter", + "dangling=true", + "--format", + "{{.Repository}}:{{.Tag}}", + ], + )) { + for img in out.stdout_lossy().lines().map(str::trim) { + if !img.is_empty() { + items.push(format!("dangling image: {img}")); + } + } + } + (items, None) + })), execute: Box::new(move |dry_run| { if exec_runner.look_path("podman").is_err() { return Ok(()); @@ -937,8 +1011,16 @@ pub(crate) fn podman_target_in(deps: &Deps) -> CleanTarget { oplog::log_outcome("podman", "system prune -f", "dry-run"); return Ok(()); } - let res = exec_runner.run(&CommandSpec::new("podman", ["system", "prune", "-f"])); - res.map(|_| ()).map_err(|e| Error::Msg(format!("podman system prune: {e}"))) + match exec_runner.run(&CommandSpec::new("podman", ["system", "prune", "-f"])) { + Ok(_) => { + oplog::log_outcome("podman", "system prune -f", "success"); + Ok(()) + } + Err(e) => { + oplog::log_outcome("podman", "system prune -f", "failure"); + Err(Error::Msg(format!("podman system prune: {e}"))) + } + } }), count_fn: None, } @@ -950,6 +1032,11 @@ pub(crate) fn orphaned_app_data_target_in(deps: &Deps) -> CleanTarget { let scan_data = deps.data_home.clone(); let scan_runner = Arc::clone(&deps.runner); + let preview_home = deps.home.clone(); + let preview_cfg = deps.config_home.clone(); + let preview_data = deps.data_home.clone(); + let preview_runner = Arc::clone(&deps.runner); + let exec_home = deps.home.clone(); let exec_cfg = deps.config_home.clone(); let exec_data = deps.data_home.clone(); @@ -961,18 +1048,46 @@ pub(crate) fn orphaned_app_data_target_in(deps: &Deps) -> CleanTarget { label: "Orphaned Application Data", requires_sudo: false, opt_in: true, + // Fail closed: a discovery error aborts the scan instead of treating + // the effective "installed" set as empty. scan: Box::new(move || { - let candidates = find_orphaned_apps(&scan_home, &scan_cfg, &scan_data, scan_runner.as_ref()); - let mut total: i64 = 0; - for p in &candidates { - let (sz, _) = crate::size::dir_size(p); - total += sz as i64; + match find_orphaned_apps(&scan_home, &scan_cfg, &scan_data, scan_runner.as_ref()) { + Err(e) => (0, Some(e)), + Ok(candidates) => { + let mut total: i64 = 0; + for p in &candidates { + let (sz, _) = size::dir_size(p); + total += sz as i64; + } + (total, None) + } } - (total, None) }), - preview: None, + // Per-path preview: the heuristic can catch user data from non-apt + // installs, so the user must see each path (and its size) before --yes. + preview: Some(Box::new(move || { + match find_orphaned_apps( + &preview_home, + &preview_cfg, + &preview_data, + preview_runner.as_ref(), + ) { + Err(e) => (Vec::new(), Some(e)), + Ok(candidates) => { + let items = candidates + .iter() + .map(|p| { + let (sz, _) = size::dir_size(p); + format!("{} ({})", p.display(), size::human_size(sz as i64)) + }) + .collect(); + (items, None) + } + } + })), execute: Box::new(move |dry_run| { - let candidates = find_orphaned_apps(&exec_home, &exec_cfg, &exec_data, exec_runner.as_ref()); + let candidates = + find_orphaned_apps(&exec_home, &exec_cfg, &exec_data, exec_runner.as_ref())?; let mut delete_errors: Vec = Vec::new(); for p in candidates { let root = if p.starts_with(&exec_cfg) { @@ -1005,50 +1120,89 @@ fn find_orphaned_apps( config_home: &Path, data_home: &Path, runner: &dyn Runner, -) -> Vec { +) -> Result> { if home.as_os_str().is_empty() { - return Vec::new(); + return Ok(Vec::new()); } + // `PROTECTED_*` is a conservative guard: known user-data dirs that do not + // map cleanly to a package name and must never be treated as orphans. const PROTECTED_CONFIG: &[&str] = &[ - "dconf", "pulse", "systemd", "autostart", "gtk-3.0", "gtk-4.0", "glib-2.0", - "fontconfig", "ibus", "gnome-session", "environment.d", "mu", "mimeapps.list", - "user-dirs.dirs", "user-dirs.locale", "gnome-control-center", "BraveSoftware", - "google-chrome", "chromium", "microsoft-edge", "mozilla", + "dconf", + "pulse", + "systemd", + "autostart", + "gtk-3.0", + "gtk-4.0", + "glib-2.0", + "fontconfig", + "ibus", + "gnome-session", + "environment.d", + "mu", + "mimeapps.list", + "user-dirs.dirs", + "user-dirs.locale", + "gnome-control-center", + "BraveSoftware", + "google-chrome", + "chromium", + "microsoft-edge", + "mozilla", + "JetBrains", + "atuin", + "containers", + "TelegramDesktop", + "Anki2", ]; const PROTECTED_DATA: &[&str] = &[ - "Trash", "applications", "icons", "themes", "fonts", "mime", "keyrings", - "systemd", "mu", "flatpak", "gvfs-metadata", "sounds", "desktop-directories", + "Trash", + "applications", + "icons", + "themes", + "fonts", + "mime", + "keyrings", + "systemd", + "mu", + "flatpak", + "gvfs-metadata", + "sounds", + "desktop-directories", + "JetBrains", + "atuin", + "containers", + "TelegramDesktop", + "Anki2", ]; let mut installed = std::collections::HashSet::new(); - if let Ok(apt_pkgs) = crate::uninstall::discover::discover_apt(runner) { - for p in apt_pkgs { - installed.insert(p.name.to_lowercase()); - if let Some(alias) = crate::uninstall::remnants::known_alias(&p.name) { - installed.insert(alias.to_lowercase()); - } + // Discovery failures abort (fail closed) — an empty set would otherwise + // make every package-owned dir look orphaned. + let apt_pkgs = crate::uninstall::discover::discover_apt(runner) + .map_err(|e| Error::Msg(format!("orphaned-app-data: discover apt: {e}")))?; + for p in apt_pkgs { + installed.insert(p.name.to_lowercase()); + if let Some(alias) = crate::uninstall::remnants::known_alias(&p.name) { + installed.insert(alias.to_lowercase()); } } - if let Ok(snap_pkgs) = crate::uninstall::discover::discover_snap(runner) { - for p in snap_pkgs { - installed.insert(p.name.to_lowercase()); - if let Some(alias) = crate::uninstall::remnants::known_alias(&p.name) { - installed.insert(alias.to_lowercase()); - } + let snap_pkgs = crate::uninstall::discover::discover_snap(runner) + .map_err(|e| Error::Msg(format!("orphaned-app-data: discover snap: {e}")))?; + for p in snap_pkgs { + installed.insert(p.name.to_lowercase()); + if let Some(alias) = crate::uninstall::remnants::known_alias(&p.name) { + installed.insert(alias.to_lowercase()); } } - if runner.look_path("flatpak").is_ok() { - if let Ok(out) = runner.run(&CommandSpec::new( - "flatpak", - ["list", "--app", "--columns=application"], - )) { - for line in out.stdout_lossy().lines() { - let id = line.trim(); - installed.insert(id.to_lowercase()); - if let Some(last) = id.split('.').last() { - installed.insert(last.to_lowercase()); - } - } + // `discover_flatpak` is empty when flatpak is absent and errors only on a + // real command failure. + let flatpak_pkgs = crate::uninstall::discover::discover_flatpak(runner) + .map_err(|e| Error::Msg(format!("orphaned-app-data: discover flatpak: {e}")))?; + for p in flatpak_pkgs { + let id = p.name.to_lowercase(); + installed.insert(id.clone()); + if let Some(last) = id.split('.').next_back() { + installed.insert(last.to_string()); } } @@ -1060,7 +1214,10 @@ fn find_orphaned_apps( continue; } let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default(); - if PROTECTED_CONFIG.iter().any(|&prot| prot.eq_ignore_ascii_case(name)) { + if PROTECTED_CONFIG + .iter() + .any(|&prot| prot.eq_ignore_ascii_case(name)) + { continue; } let lower = name.to_lowercase(); @@ -1080,7 +1237,10 @@ fn find_orphaned_apps( continue; } let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default(); - if PROTECTED_DATA.iter().any(|&prot| prot.eq_ignore_ascii_case(name)) { + if PROTECTED_DATA + .iter() + .any(|&prot| prot.eq_ignore_ascii_case(name)) + { continue; } let lower = name.to_lowercase(); @@ -1094,38 +1254,44 @@ fn find_orphaned_apps( } } candidates.sort(); - candidates + Ok(candidates) } pub(crate) fn orphaned_services_target_in(deps: &Deps) -> CleanTarget { - let scan_home = deps.home.clone(); - let exec_home = deps.home.clone(); + let scan_cfg = deps.config_home.clone(); + let preview_cfg = deps.config_home.clone(); let exec_cfg = deps.config_home.clone(); let exec_runner = Arc::clone(&deps.trash_runner); let exec_deps = deps.trash_deps.clone(); CleanTarget { id: "orphaned-services", - label: "Orphaned System Services", + label: "Orphaned User Services", requires_sudo: false, opt_in: true, + // Broken enablement symlinks live in `.wants/` subdirs, so the + // scan recurses the whole user unit tree rather than its top level. scan: Box::new(move || { - let broken = find_broken_service_symlinks(&scan_home); - let total = (broken.len() as i64) * 1024; + let broken = find_broken_service_symlinks(&scan_cfg); + let total = broken.iter().map(|p| symlink_len(p)).sum(); (total, None) }), - preview: None, + preview: Some(Box::new(move || { + let broken = find_broken_service_symlinks(&preview_cfg); + let items = broken + .iter() + .map(|p| format!("{} (broken symlink)", p.display())) + .collect(); + (items, None) + })), execute: Box::new(move |dry_run| { - let broken = find_broken_service_symlinks(&exec_home); + let broken = find_broken_service_symlinks(&exec_cfg); let mut delete_errors: Vec = Vec::new(); - let user_dir = exec_home.join(".config/systemd/user"); for p in broken { - if paths::path_contains(&user_dir, &p) { - if let Err(e) = - trash::safe_delete_with(&*exec_runner, &exec_cfg, &exec_deps, &p, dry_run) - { - delete_errors.push(e); - } + if let Err(e) = + trash::safe_delete_with(&*exec_runner, &exec_cfg, &exec_deps, &p, dry_run) + { + delete_errors.push(e); } } if delete_errors.is_empty() { @@ -1138,21 +1304,39 @@ pub(crate) fn orphaned_services_target_in(deps: &Deps) -> CleanTarget { } } -fn find_broken_service_symlinks(home: &Path) -> Vec { +/// Recursively collect dangling symlinks under `/systemd/user` +/// (including `*.wants/`), without following symlinked directories. +fn find_broken_service_symlinks(config_home: &Path) -> Vec { let mut broken = Vec::new(); - let user_dir = home.join(".config/systemd/user"); - if let Ok(entries) = std::fs::read_dir(user_dir) { - for entry in entries.flatten() { - let p = entry.path(); - if let Ok(m) = p.symlink_metadata() { - if m.file_type().is_symlink() && std::fs::metadata(&p).is_err() { - broken.push(p); - } + collect_broken_symlinks(&config_home.join("systemd/user"), &mut broken); + broken.sort(); + broken +} + +fn collect_broken_symlinks(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let p = entry.path(); + let Ok(meta) = p.symlink_metadata() else { + continue; + }; + if meta.file_type().is_symlink() { + if std::fs::metadata(&p).is_err() { + out.push(p); } + } else if meta.is_dir() { + collect_broken_symlinks(&p, out); } } - broken.sort(); - broken +} + +/// `os.Lstat().Size()` — the byte length of the symlink target string. +fn symlink_len(p: &Path) -> i64 { + std::fs::symlink_metadata(p) + .map(|m| m.len() as i64) + .unwrap_or(0) } #[cfg(test)] @@ -1601,4 +1785,178 @@ mod tests { fs::remove_dir_all(&tmp).ok(); } } + + // Finding 1 (review p3 f1): a package-discovery failure must abort the + // orphan scan instead of treating the installed set as empty, and execute + // must delete nothing. + #[test] + fn orphaned_app_data_fails_closed_on_discovery_error() { + use crate::runner::RunError; + let tmp = tempdir("orphan-fail"); + let cfg = tmp.join(".config"); + let data = tmp.join("data-home"); + fs::create_dir_all(cfg.join("ghostapp")).unwrap(); + fs::write(cfg.join("ghostapp/x"), "user data").unwrap(); + fs::create_dir_all(&data).unwrap(); + + let runner = Arc::new(FakeRunner::new()); + runner.set_handler(|spec| { + if spec.program.to_string_lossy() == "dpkg-query" { + return Err(RunError::Spawn(std::io::Error::other("dpkg lock held"))); + } + Ok(Output::default()) + }); + // The execute path discovers through `trash_runner`; point it at the + // same failing runner so the fail-closed branch is exercised. + let mut deps = deps_for_test(&tmp, runner.clone() as Arc); + deps.trash_runner = runner.clone() as Arc; + let target = orphaned_app_data_target_in(&deps); + + let (_sz, err) = (target.scan)(); + assert!(err.is_some(), "scan must fail closed on discovery error"); + let exec_err = (target.execute)(false).expect_err("execute must refuse"); + assert!( + exec_err.to_string().contains("discover apt"), + "unexpected error: {exec_err}" + ); + assert!( + cfg.join("ghostapp").exists(), + "fail-closed execute deleted user data" + ); + fs::remove_dir_all(&tmp).ok(); + } + + // Finding 2 (review p3 f2): preview lists every candidate path (not just a + // total) and protected user-data dirs are excluded. + #[test] + fn orphaned_app_data_preview_lists_paths_and_protects_user_data() { + let tmp = tempdir("orphan-preview"); + let cfg = tmp.join(".config"); + let data = tmp.join("data-home"); + fs::create_dir_all(cfg.join("ghostapp")).unwrap(); + fs::write(cfg.join("ghostapp/x"), "hello").unwrap(); + fs::create_dir_all(data.join("ghostdata")).unwrap(); + fs::write(data.join("ghostdata/y"), "world").unwrap(); + // Protected data dir (podman images/volumes) — never a candidate. + fs::create_dir_all(data.join("containers")).unwrap(); + fs::write(data.join("containers/z"), "podman images").unwrap(); + + let runner = Arc::new(FakeRunner::new()); + runner.set_handler(|spec| { + if spec.program.to_string_lossy() == "dpkg-query" { + return Ok(Output { + stdout: b"install ok installed\t10\tfoo\t1.0\n".to_vec(), + ..Default::default() + }); + } + Ok(Output::default()) + }); + let deps = deps_for_test(&tmp, runner); + let target = orphaned_app_data_target_in(&deps); + + let (items, err) = (target.preview.as_ref().expect("preview must exist"))(); + assert!(err.is_none(), "preview error: {err:?}"); + let joined = items.join("\n"); + assert!( + joined.contains(&cfg.join("ghostapp").display().to_string()), + "missing config candidate in preview: {joined}" + ); + assert!( + joined.contains(&data.join("ghostdata").display().to_string()), + "missing data candidate in preview: {joined}" + ); + assert!( + !joined.contains("containers"), + "protected dir must not be listed: {joined}" + ); + fs::remove_dir_all(&tmp).ok(); + } + + // Finding 3 (review p3 f3): the Podman target reports a size from + // `podman system df` and lists what prune would remove. + #[test] + fn podman_reports_size_and_previews_targets() { + let tmp = tempdir("podman"); + let runner = Arc::new(FakeRunner::new()); + runner.set_look_path("podman", "/usr/bin/podman"); + runner.set_handler(|spec| { + let prog = spec.program.to_string_lossy(); + let args: Vec = spec + .args + .iter() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + let has = |needle: &str| args.iter().any(|a| a == needle); + if prog == "podman" && has("df") { + return Ok(Output { + stdout: b"{\"Type\":\"Images\",\"Reclaimable\":\"1.5GB\"}\n\ +{\"Type\":\"Containers\",\"Reclaimable\":\"512MB\"}\n" + .to_vec(), + ..Default::default() + }); + } + if prog == "podman" && has("container") { + return Ok(Output { + stdout: b"oldctr\n".to_vec(), + ..Default::default() + }); + } + if prog == "podman" && has("image") { + return Ok(Output { + stdout: b":\n".to_vec(), + ..Default::default() + }); + } + Ok(Output::default()) + }); + let deps = deps_for_test(&tmp, runner.clone() as Arc); + let target = podman_target_in(&deps); + + let (sz, err) = (target.scan)(); + assert!(err.is_none(), "scan error: {err:?}"); + let want = (1.5 * 1024.0 * 1024.0 * 1024.0) as i64 + 512 * 1024 * 1024; + assert_eq!(sz, want, "podman reclaimable size"); + + let (items, perr) = (target.preview.as_ref().expect("preview"))(); + assert!(perr.is_none()); + let joined = items.join("\n"); + assert!(joined.contains("stopped container: oldctr"), "{joined}"); + assert!(joined.contains("dangling image: :"), "{joined}"); + + (target.execute)(true).expect("dry-run execute"); + assert!( + !runner + .invocations() + .iter() + .any(|s| s.args.iter().any(|a| a == "prune")), + "dry-run must not invoke podman prune" + ); + fs::remove_dir_all(&tmp).ok(); + } + + // Finding 5 (review p3 f5): the scan recurses `*.wants/`, honors the + // configured config home, and reports the real symlink size. + #[test] + fn orphaned_services_scans_wants_dirs_and_reports_real_size() { + let tmp = tempdir("svc"); + let cfg = tmp.join(".config"); + let wants = cfg.join("systemd/user/default.target.wants"); + fs::create_dir_all(&wants).unwrap(); + let target_path = "/nonexistent/ghost.service"; + std::os::unix::fs::symlink(target_path, wants.join("ghost.service")).unwrap(); + // A valid symlink and a plain file are not orphans. + std::os::unix::fs::symlink("/bin/true", cfg.join("systemd/user/real.service")).unwrap(); + fs::write(cfg.join("systemd/user/plain.service"), "x").unwrap(); + + let deps = deps_for_test(&tmp, Arc::new(FakeRunner::new())); + let target = orphaned_services_target_in(&deps); + let (sz, err) = (target.scan)(); + assert!(err.is_none()); + assert_eq!(sz, target_path.len() as i64, "reported size={sz}"); + let (items, perr) = (target.preview.as_ref().expect("preview"))(); + assert!(perr.is_none()); + assert_eq!(items.len(), 1, "items={items:?}"); + assert!(items[0].contains("ghost.service"), "{items:?}"); + fs::remove_dir_all(&tmp).ok(); + } } diff --git a/src/optimize.rs b/src/optimize.rs index 83a3940..cadc21f 100644 --- a/src/optimize.rs +++ b/src/optimize.rs @@ -80,9 +80,7 @@ impl Deps { config_home: xdg::config_home(), data_home: xdg::data_home(), home: xdg::home_dir(), - state_home: std::env::var_os("XDG_STATE_HOME") - .map(PathBuf::from) - .unwrap_or_else(|| xdg::home_dir().join(".local/state")), + state_home: xdg::state_home(), trash_deps: Rc::new(trash::TrashDeps::real()), no_oplog: std::env::var_os("MU_NO_OPLOG").is_some_and(|v| v == "1"), } @@ -157,7 +155,7 @@ fn all_steps(deps: &Deps) -> Vec { Step { id: "dns", desc: "flush DNS cache", - dry_run_cmd: "resolvectl flush-caches", + dry_run_cmd: "sudo resolvectl flush-caches", dry_run_lines: None, run: { let runner = deps.runner.clone(); @@ -177,7 +175,7 @@ fn all_steps(deps: &Deps) -> Vec { Step { id: "systemd", desc: "reset failed systemd units", - dry_run_cmd: "systemctl reset-failed && systemctl --user reset-failed", + dry_run_cmd: "sudo systemctl reset-failed && systemctl --user reset-failed", dry_run_lines: None, run: { let runner = deps.runner.clone(); @@ -624,22 +622,34 @@ fn update_caches(runner: &dyn Runner, out: &mut dyn Write) -> Result<()> { } fn dns_flush(runner: &dyn Runner, out: &mut dyn Write) -> Result<()> { + // `flush-caches` is polkit-gated (`org.freedesktop.resolve1.flush-caches`), + // so the system half must run elevated or every default run fails without + // a polkit agent (plan Conflict 2: root-level steps run with sudo). if runner.look_path("resolvectl").is_ok() { - let res = runner.run(&CommandSpec::new("resolvectl", ["flush-caches"])); + let res = runner.run(&CommandSpec::new("sudo", ["resolvectl", "flush-caches"])); write_captured(out, &res); if let Err(e) = res { if runner.look_path("systemd-resolve").is_ok() { - let res2 = runner.run(&CommandSpec::new("systemd-resolve", ["--flush-caches"])); + let res2 = runner.run(&CommandSpec::new( + "sudo", + ["systemd-resolve", "--flush-caches"], + )); write_captured(out, &res2); - res2.map(|_| ()).map_err(|e2| Error::Msg(format!("systemd-resolve --flush-caches: {e2}")))?; + res2.map(|_| ()).map_err(|e2| { + Error::Msg(format!("sudo systemd-resolve --flush-caches: {e2}")) + })?; } else { - return Err(Error::Msg(format!("resolvectl flush-caches: {e}"))); + return Err(Error::Msg(format!("sudo resolvectl flush-caches: {e}"))); } } } else if runner.look_path("systemd-resolve").is_ok() { - let res = runner.run(&CommandSpec::new("systemd-resolve", ["--flush-caches"])); + let res = runner.run(&CommandSpec::new( + "sudo", + ["systemd-resolve", "--flush-caches"], + )); write_captured(out, &res); - res.map(|_| ()).map_err(|e| Error::Msg(format!("systemd-resolve --flush-caches: {e}")))?; + res.map(|_| ()) + .map_err(|e| Error::Msg(format!("sudo systemd-resolve --flush-caches: {e}")))?; } else { let _ = writeln!(out, "neither resolvectl nor systemd-resolve found; skipped"); } @@ -651,7 +661,10 @@ fn packages_repair(runner: &dyn Runner, out: &mut dyn Write) -> Result<()> { write_captured(out, &res1); let err1 = res1.err(); - let res2 = runner.run(&CommandSpec::new("sudo", ["apt-get", "install", "-f", "-y"])); + let res2 = runner.run(&CommandSpec::new( + "sudo", + ["apt-get", "install", "-f", "-y"], + )); write_captured(out, &res2); let err2 = res2.err(); @@ -665,7 +678,9 @@ fn packages_repair(runner: &dyn Runner, out: &mut dyn Write) -> Result<()> { fn systemd_reset_failed(runner: &dyn Runner, out: &mut dyn Write) -> Result<()> { let mut errs = Vec::new(); - let res1 = runner.run(&CommandSpec::new("systemctl", ["reset-failed"])); + // The system scope needs root (polkit `manage-units` otherwise); the user + // scope runs unprivileged (plan Conflict 2). + let res1 = runner.run(&CommandSpec::new("sudo", ["systemctl", "reset-failed"])); write_captured(out, &res1); if let Err(e) = res1 { errs.push(e.to_string()); @@ -687,9 +702,13 @@ fn flatpak_clean_unused(runner: &dyn Runner, out: &mut dyn Write) -> Result<()> let _ = writeln!(out, "flatpak not found; skipped"); return Ok(()); } - let res = runner.run(&CommandSpec::new("flatpak", ["uninstall", "--unused", "-y"])); + let res = runner.run(&CommandSpec::new( + "flatpak", + ["uninstall", "--unused", "-y"], + )); write_captured(out, &res); - res.map(|_| ()).map_err(|e| Error::Msg(format!("flatpak uninstall --unused: {e}")))?; + res.map(|_| ()) + .map_err(|e| Error::Msg(format!("flatpak uninstall --unused: {e}")))?; Ok(()) } @@ -807,7 +826,11 @@ fn sqlite_vacuum(runner: &dyn Runner, home: &Path, out: &mut dyn Write) -> Resul for db in candidates { let db_str = db.to_string_lossy(); if firefox_running && db_str.contains(".mozilla") { - let _ = writeln!(out, "sqlite: skipping {} (firefox is running)", db.display()); + let _ = writeln!( + out, + "sqlite: skipping {} (firefox is running)", + db.display() + ); continue; } if chrome_running && (db_str.contains("google-chrome") || db_str.contains("chromium")) { @@ -815,19 +838,24 @@ fn sqlite_vacuum(runner: &dyn Runner, home: &Path, out: &mut dyn Write) -> Resul continue; } if thunderbird_running && db_str.contains(".thunderbird") { - let _ = writeln!(out, "sqlite: skipping {} (thunderbird is running)", db.display()); + let _ = writeln!( + out, + "sqlite: skipping {} (thunderbird is running)", + db.display() + ); continue; } let wal = db.with_extension("sqlite-wal"); let journal = db.with_extension("sqlite-journal"); if wal.exists() || journal.exists() { - let _ = writeln!(out, "sqlite: skipping {} (active lock/journal)", db.display()); + let _ = writeln!( + out, + "sqlite: skipping {} (active lock/journal)", + db.display() + ); continue; } - let res = runner.run(&CommandSpec::new( - "sqlite3", - [db_str.as_ref(), "VACUUM;"], - )); + let res = runner.run(&CommandSpec::new("sqlite3", [db_str.as_ref(), "VACUUM;"])); if let Err(e) = res { let _ = writeln!(out, "sqlite: vacuum warning for {}: {}", db.display(), e); } else { @@ -838,6 +866,12 @@ fn sqlite_vacuum(runner: &dyn Runner, home: &Path, out: &mut dyn Write) -> Resul Ok(()) } +/// Subtrees under `~/.local/state` that hold live user state rather than +/// disposable transient data (undo/shada history, audio defaults, playback +/// bookmarks). Deleting stale files inside them loses recoverable user work, +/// so the stale-state step never touches them. +const LIVE_STATE_DIRS: &[&str] = &["nvim", "wireplumber", "mpv"]; + fn state_clean( runner: &dyn Runner, config_home: &Path, @@ -849,42 +883,92 @@ fn state_clean( let _ = writeln!(out, "state: state directory not found"); return Ok(()); } + // Fail closed: the state root must be an absolute, non-symlinked, + // non-protected directory before anything under it is deleted. + crate::paths::validate_cleanup_root(state_home)?; let now = std::time::SystemTime::now(); let thirty_days = std::time::Duration::from_secs(30 * 86400); + let histfile = std::env::var_os("HISTFILE").map(PathBuf::from); let mut cleaned = 0; - clean_stale_recursive(runner, config_home, state_home, now, thirty_days, trash_deps, &mut cleaned)?; - let _ = writeln!(out, "state: cleaned {cleaned} stale files older than 30 days"); - Ok(()) + let mut errors: Vec = Vec::new(); + clean_stale_recursive( + runner, + config_home, + state_home, + state_home, + now, + thirty_days, + histfile.as_deref(), + trash_deps, + &mut cleaned, + &mut errors, + )?; + let _ = writeln!( + out, + "state: cleaned {cleaned} stale files older than 30 days" + ); + if errors.is_empty() { + Ok(()) + } else { + Err(Error::Msg( + errors + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"), + )) + } } +#[allow(clippy::too_many_arguments)] fn clean_stale_recursive( runner: &dyn Runner, config_home: &Path, + state_home: &Path, dir: &Path, now: std::time::SystemTime, max_age: std::time::Duration, + histfile: Option<&Path>, trash_deps: &trash::TrashDeps, cleaned: &mut usize, + errors: &mut Vec, ) -> Result<()> { let Ok(entries) = std::fs::read_dir(dir) else { return Ok(()); }; for entry in entries.flatten() { let p = entry.path(); + if is_live_state(state_home, &p, histfile) { + continue; + } if let Ok(meta) = p.symlink_metadata() { if meta.is_dir() && !meta.file_type().is_symlink() { - clean_stale_recursive(runner, config_home, &p, now, max_age, trash_deps, cleaned)?; + clean_stale_recursive( + runner, + config_home, + state_home, + &p, + now, + max_age, + histfile, + trash_deps, + cleaned, + errors, + )?; } else if meta.is_file() { if let Ok(mtime) = meta.modified() { if let Ok(age) = now.duration_since(mtime) { if age >= max_age { - if trash::safe_delete_with( - runner, - config_home, - trash_deps, - &p, - false, - ).is_ok() { + if let Err(e) = crate::paths::validate_cleanup_candidate(state_home, &p) + { + errors.push(e); + continue; + } + if let Err(e) = + trash::safe_delete_with(runner, config_home, trash_deps, &p, false) + { + errors.push(e); + } else { *cleaned += 1; } } @@ -896,6 +980,25 @@ fn clean_stale_recursive( Ok(()) } +/// True when `p` is live user state the stale-state step must never delete: +/// anything inside a [`LIVE_STATE_DIRS`] subtree, or the file `$HISTFILE` +/// points at. +fn is_live_state(state_home: &Path, p: &Path, histfile: Option<&Path>) -> bool { + if let Some(hf) = histfile { + if hf == p { + return true; + } + } + if let Ok(rel) = p.strip_prefix(state_home) { + if let Some(std::path::Component::Normal(first)) = rel.components().next() { + return LIVE_STATE_DIRS + .iter() + .any(|d| first == std::ffi::OsStr::new(d)); + } + } + false +} + fn autostart_audit(runner: &dyn Runner, config_home: &Path, out: &mut dyn Write) -> Result<()> { let autostart_dir = config_home.join("autostart"); if !autostart_dir.is_dir() { @@ -969,7 +1072,11 @@ fn services_audit(config_home: &Path, out: &mut dyn Write) -> Result<()> { if broken.is_empty() { let _ = writeln!(out, "services: no broken unit symlinks found"); } else { - let _ = writeln!(out, "services: found {} broken unit symlinks:", broken.len()); + let _ = writeln!( + out, + "services: found {} broken unit symlinks:", + broken.len() + ); for b in broken { let _ = writeln!(out, " - {}", b); } @@ -986,7 +1093,11 @@ fn permissions_audit(home: &Path, out: &mut dyn Write) -> Result<()> { if root_owned.is_empty() { let _ = writeln!(out, "permissions: no root-owned files found in $HOME"); } else { - let _ = writeln!(out, "permissions: found {} root-owned files in $HOME:", root_owned.len()); + let _ = writeln!( + out, + "permissions: found {} root-owned files in $HOME:", + root_owned.len() + ); for r in root_owned.iter().take(10) { let _ = writeln!(out, " - {}", r); } @@ -1012,8 +1123,16 @@ fn audit_perms_dir(dir: &Path, depth: usize, max_depth: usize, root_owned: &mut root_owned.push(path.display().to_string()); } if meta.is_dir() && !meta.file_type().is_symlink() { - let name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default(); - if name == ".git" || name == "node_modules" || name == "target" || name == ".cargo" || name == ".rustup" { + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default(); + if name == ".git" + || name == "node_modules" + || name == "target" + || name == ".cargo" + || name == ".rustup" + { continue; } audit_perms_dir(&path, depth + 1, max_depth, root_owned); @@ -1028,7 +1147,11 @@ fn disk_health_audit(runner: &dyn Runner, out: &mut dyn Write) -> Result<()> { let res = runner.run(&CommandSpec::new("smartctl", ["--scan"])); if let Ok(output) = res { if !output.stdout.is_empty() { - let _ = writeln!(out, "SMART devices detected:\n{}", output.stdout_lossy().trim()); + let _ = writeln!( + out, + "SMART devices detected:\n{}", + output.stdout_lossy().trim() + ); probed = true; } } @@ -1042,10 +1165,19 @@ fn disk_health_audit(runner: &dyn Runner, out: &mut dyn Write) -> Result<()> { let text = output.stdout_lossy(); let fs_errors: Vec<&str> = text .lines() - .filter(|l| l.contains("EXT4-fs") || l.contains("BTRFS") || l.contains("XFS") || l.contains("I/O error")) + .filter(|l| { + l.contains("EXT4-fs") + || l.contains("BTRFS") + || l.contains("XFS") + || l.contains("I/O error") + }) .collect(); if !fs_errors.is_empty() { - let _ = writeln!(out, "Filesystem alerts in kernel log:\n{}", fs_errors.join("\n")); + let _ = writeln!( + out, + "Filesystem alerts in kernel log:\n{}", + fs_errors.join("\n") + ); probed = true; } } @@ -1398,9 +1530,144 @@ mod tests { assert_eq!(summary, "Dry run — nothing executed."); assert_eq!( String::from_utf8_lossy(&out), - "\n Optimize plan\n [dry-run] sudo apt-get update && sudo apt-get autoremove --purge -y\n [skip] journal\n [dry-run] sudo update-mime-database /usr/share/mime && fc-cache -f\n [dry-run] resolvectl flush-caches\n [dry-run] sudo dpkg --configure -a && sudo apt-get install -f -y\n [dry-run] systemctl reset-failed && systemctl --user reset-failed\n [dry-run] flatpak uninstall --unused -y\n [dry-run] sqlite3 \"VACUUM;\"\n [dry-run] find ~/.local/state -type f -mtime +30 -delete\n [dry-run] audit ~/.config/autostart/*.desktop for missing Exec binaries\n [dry-run] audit broken symlinks in /etc/systemd/system and ~/.config/systemd/user\n [dry-run] audit files in $HOME owned by root\n [dry-run] smartctl --scan || journalctl -k --grep=EXT4-fs\n Dry run — nothing executed.\n" + "\n Optimize plan\n [dry-run] sudo apt-get update && sudo apt-get autoremove --purge -y\n [skip] journal\n [dry-run] sudo update-mime-database /usr/share/mime && fc-cache -f\n [dry-run] sudo resolvectl flush-caches\n [dry-run] sudo dpkg --configure -a && sudo apt-get install -f -y\n [dry-run] sudo systemctl reset-failed && systemctl --user reset-failed\n [dry-run] flatpak uninstall --unused -y\n [dry-run] sqlite3 \"VACUUM;\"\n [dry-run] find ~/.local/state -type f -mtime +30 -delete\n [dry-run] audit ~/.config/autostart/*.desktop for missing Exec binaries\n [dry-run] audit broken symlinks in /etc/systemd/system and ~/.config/systemd/user\n [dry-run] audit files in $HOME owned by root\n [dry-run] smartctl --scan || journalctl -k --grep=EXT4-fs\n Dry run — nothing executed.\n" ); assert!(runner.invocations().is_empty()); fs::remove_dir_all(&root).ok(); } + + fn trash_deps_at(data_home: PathBuf) -> trash::TrashDeps { + trash::TrashDeps { + data_home: Box::new(move || { + if !data_home.exists() { + std::fs::create_dir_all(&data_home)?; + } + Ok(data_home.clone()) + }), + ..trash::TrashDeps::real() + } + } + + fn set_mtime_secs_ago(p: &Path, secs: u64) { + let f = fs::File::options().write(true).open(p).unwrap(); + f.set_times( + fs::FileTimes::new() + .set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(secs)), + ) + .unwrap(); + } + + // Finding 4 (review p3 f4): the state step trashes stale files through + // SafeDelete but never live user state, and surfaces trash failures. + #[test] + fn state_clean_trashes_stale_files_only() { + let root = tempdir("opt-state"); + let config_home = root.join("config"); + let state = root.join("state"); + fs::create_dir_all(&config_home).unwrap(); + fs::create_dir_all(state.join("nvim")).unwrap(); + + let stale = state.join("cache.log"); + fs::write(&stale, "stale state").unwrap(); + set_mtime_secs_ago(&stale, 40 * 86400); + let fresh = state.join("recent.log"); + fs::write(&fresh, "fresh state").unwrap(); + let live = state.join("nvim").join("undo.log"); + fs::write(&live, "nvim undo history").unwrap(); + set_mtime_secs_ago(&live, 40 * 86400); + + let runner = FakeRunner::new(); + let trash_deps = trash_deps_at(root.join("data-home")); + let mut out = Vec::new(); + state_clean(&runner, &config_home, &state, &trash_deps, &mut out).expect("state clean"); + + assert!(!stale.exists(), "stale file must be trashed"); + assert!(fresh.exists(), "fresh file must be kept"); + assert!(live.exists(), "live nvim state must be kept"); + let text = String::from_utf8_lossy(&out); + assert!( + text.contains("state: cleaned 1 stale files"), + "got {text:?}" + ); + fs::remove_dir_all(&root).ok(); + } + + // Finding 4 (review p3 f4): a symlinked state root is rejected (SafeDelete + // cleanup-root invariant). + #[test] + fn state_clean_rejects_symlinked_root() { + let root = tempdir("opt-state-symlink"); + let config_home = root.join("config"); + let real = root.join("real-state"); + fs::create_dir_all(&config_home).unwrap(); + fs::create_dir_all(&real).unwrap(); + let link = root.join("link-state"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + + let runner = FakeRunner::new(); + let trash_deps = trash_deps_at(root.join("data-home")); + let mut out = Vec::new(); + let err = state_clean(&runner, &config_home, &link, &trash_deps, &mut out) + .expect_err("symlinked state root must be rejected"); + assert!(err.to_string().contains("symlink"), "got {err}"); + fs::remove_dir_all(&root).ok(); + } + + // Finding 4 (review p3 f4): a SafeDelete failure is reported, not swallowed. + #[test] + fn state_clean_surfaces_trash_errors() { + let root = tempdir("opt-state-err"); + let config_home = root.join("config"); + let state = root.join("state"); + fs::create_dir_all(&config_home).unwrap(); + fs::create_dir_all(&state).unwrap(); + let stale = state.join("cache.log"); + fs::write(&stale, "stale state").unwrap(); + set_mtime_secs_ago(&stale, 40 * 86400); + + let runner = FakeRunner::new(); + let bad_trash = trash::TrashDeps { + data_home: Box::new(|| Err(Error::Msg("no trash available".to_string()))), + ..trash::TrashDeps::real() + }; + let mut out = Vec::new(); + let err = state_clean(&runner, &config_home, &state, &bad_trash, &mut out) + .expect_err("trash failure must surface"); + assert!(err.to_string().contains("trash"), "got {err}"); + fs::remove_dir_all(&root).ok(); + } + + // Finding 6 (review p3 f6): root-level commands run under sudo. + #[test] + fn root_scoped_steps_use_sudo() { + let runner = FakeRunner::new(); + let mut out = Vec::new(); + systemd_reset_failed(&runner, &mut out).expect("systemd step"); + let inv = runner.invocations(); + assert_eq!(inv[0].program.to_string_lossy(), "sudo"); + assert_eq!( + inv[0].args, + vec![ + std::ffi::OsString::from("systemctl"), + std::ffi::OsString::from("reset-failed") + ] + ); + assert_eq!(inv[1].program.to_string_lossy(), "systemctl"); + assert_eq!( + inv[1].args, + vec![ + std::ffi::OsString::from("--user"), + std::ffi::OsString::from("reset-failed") + ] + ); + + let dns_runner = FakeRunner::new(); + dns_runner.set_look_path("resolvectl", "/usr/bin/resolvectl"); + let mut out = Vec::new(); + dns_flush(&dns_runner, &mut out).expect("dns step"); + let inv = dns_runner.invocations(); + assert_eq!(inv[0].program.to_string_lossy(), "sudo"); + assert_eq!(inv[0].args[0].to_string_lossy(), "resolvectl"); + assert_eq!(inv[0].args[1].to_string_lossy(), "flush-caches"); + } } diff --git a/src/xdg.rs b/src/xdg.rs index 6a7e57e..2203634 100644 --- a/src/xdg.rs +++ b/src/xdg.rs @@ -70,6 +70,15 @@ pub fn data_home() -> PathBuf { ) } +/// `$XDG_STATE_HOME` or `~/.local/state`. Relative values are ignored like +/// the other XDG roots (fail closed). +pub fn state_home() -> PathBuf { + resolve( + env::var_os("XDG_STATE_HOME").as_deref(), + home_dir().join(".local").join("state"), + ) +} + #[cfg(test)] mod tests { use super::*; @@ -95,6 +104,11 @@ mod tests { home.join(".local").join("share"), "relative XDG_DATA_HOME must be ignored" ); + assert_eq!( + resolve(rel, home.join(".local").join("state")), + home.join(".local").join("state"), + "relative XDG_STATE_HOME must be ignored" + ); } #[test] diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 488c58f..ba7cf45 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -446,7 +446,7 @@ fn optimize_dry_run_exact_commands() { "missing exact journal command: {out}" ); assert!( - out.contains("[dry-run] resolvectl flush-caches"), + out.contains("[dry-run] sudo resolvectl flush-caches"), "missing exact dns command: {out}" ); assert!( @@ -458,7 +458,7 @@ fn optimize_dry_run_exact_commands() { "missing exact packages command: {out}" ); assert!( - out.contains("[dry-run] systemctl reset-failed && systemctl --user reset-failed"), + out.contains("[dry-run] sudo systemctl reset-failed && systemctl --user reset-failed"), "missing exact systemd command: {out}" ); } @@ -534,7 +534,7 @@ fn clean_dry_run_all_opt_in_targets() { "missing orphaned-app-data: {out}" ); assert!( - out.contains("Orphaned System Services"), + out.contains("Orphaned User Services"), "missing orphaned-services: {out}" ); } From d8fcbd883bccfb78291d1fab922900fa5d9a45f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ba=20R=E1=BB=8Di?= Date: Fri, 16 Oct 2026 17:21:33 +0700 Subject: [PATCH 11/17] fix(clean,optimize): address mufix-p3 review r1 findings - state step: protect shell history by name (history, *_history, lesshst) and match HISTFILE by base name, since HISTFILE is a shell variable and is almost never exported (r1 f1). - podman: preview now propagates podman ls failures as preview_err and also lists created containers; the comment no longer claims an exact list (r1 f2). - orphaned-app-data: execute discovers packages through the same runner as scan/preview (the trash runner is only for SafeDelete), so the delete set matches the previewed set (r1 f3). - add a test per fixed behaviour. --- src/clean/targets.rs | 127 ++++++++++++++++++++++++++++++------------- src/optimize.rs | 68 ++++++++++++++++++++++- 2 files changed, 154 insertions(+), 41 deletions(-) diff --git a/src/clean/targets.rs b/src/clean/targets.rs index d5016c4..3a0e7ff 100644 --- a/src/clean/targets.rs +++ b/src/clean/targets.rs @@ -960,48 +960,68 @@ pub(crate) fn podman_target_in(deps: &Deps) -> CleanTarget { } }), // `podman system prune -f` is irreversible and cannot route through - // SafeDelete, so list exactly what it would remove before confirming. + // SafeDelete. This lists the main prune classes (partial: pods, + // networks and build cache are not itemised) and surfaces any `ls` + // failure so `--yes` cannot confirm an unseen set. preview: Some(Box::new(move || { if preview_runner.look_path("podman").is_err() { return (Vec::new(), None); } let mut items = Vec::new(); - if let Ok(out) = preview_runner.run(&CommandSpec::new( - "podman", - [ - "container", - "ls", - "-a", - "--filter", - "status=exited", - "--format", - "{{.Names}}", - ], - )) { - for name in out.stdout_lossy().lines().map(str::trim) { - if !name.is_empty() { - items.push(format!("stopped container: {name}")); + let mut errs: Vec = Vec::new(); + for (args, prefix) in [ + ( + vec![ + "container", + "ls", + "-a", + "--filter", + "status=exited", + "--format", + "{{.Names}}", + ], + "stopped container", + ), + ( + vec![ + "container", + "ls", + "-a", + "--filter", + "status=created", + "--format", + "{{.Names}}", + ], + "created container", + ), + ( + vec![ + "image", + "ls", + "--filter", + "dangling=true", + "--format", + "{{.Repository}}:{{.Tag}}", + ], + "dangling image", + ), + ] { + match preview_runner.run(&CommandSpec::new("podman", args)) { + Err(e) => errs.push(Error::Msg(format!("podman {prefix} list: {e}"))), + Ok(out) => { + for v in out.stdout_lossy().lines().map(str::trim) { + if !v.is_empty() { + items.push(format!("{prefix}: {v}")); + } + } } } } - if let Ok(out) = preview_runner.run(&CommandSpec::new( - "podman", - [ - "image", - "ls", - "--filter", - "dangling=true", - "--format", - "{{.Repository}}:{{.Tag}}", - ], - )) { - for img in out.stdout_lossy().lines().map(str::trim) { - if !img.is_empty() { - items.push(format!("dangling image: {img}")); - } - } + if errs.is_empty() { + (items, None) + } else { + (items, Some(super::join_errors(&errs))) } - (items, None) })), execute: Box::new(move |dry_run| { if exec_runner.look_path("podman").is_err() { @@ -1040,6 +1060,9 @@ pub(crate) fn orphaned_app_data_target_in(deps: &Deps) -> CleanTarget { let exec_home = deps.home.clone(); let exec_cfg = deps.config_home.clone(); let exec_data = deps.data_home.clone(); + // Discovery at execute time uses the same runner as scan/preview; the + // trash runner is only for SafeDelete (gio/trash), never for discovery. + let exec_disc_runner = Arc::clone(&deps.runner); let exec_runner = Arc::clone(&deps.trash_runner); let exec_deps = deps.trash_deps.clone(); @@ -1087,7 +1110,7 @@ pub(crate) fn orphaned_app_data_target_in(deps: &Deps) -> CleanTarget { })), execute: Box::new(move |dry_run| { let candidates = - find_orphaned_apps(&exec_home, &exec_cfg, &exec_data, exec_runner.as_ref())?; + find_orphaned_apps(&exec_home, &exec_cfg, &exec_data, exec_disc_runner.as_ref())?; let mut delete_errors: Vec = Vec::new(); for p in candidates { let root = if p.starts_with(&exec_cfg) { @@ -1806,10 +1829,9 @@ mod tests { } Ok(Output::default()) }); - // The execute path discovers through `trash_runner`; point it at the - // same failing runner so the fail-closed branch is exercised. - let mut deps = deps_for_test(&tmp, runner.clone() as Arc); - deps.trash_runner = runner.clone() as Arc; + // Execute discovers through the same runner as scan/preview (finding 3 + // fix), so no repointing is needed here. + let deps = deps_for_test(&tmp, runner.clone() as Arc); let target = orphaned_app_data_target_in(&deps); let (_sz, err) = (target.scan)(); @@ -1921,6 +1943,7 @@ mod tests { assert!(perr.is_none()); let joined = items.join("\n"); assert!(joined.contains("stopped container: oldctr"), "{joined}"); + assert!(joined.contains("created container: oldctr"), "{joined}"); assert!(joined.contains("dangling image: :"), "{joined}"); (target.execute)(true).expect("dry-run execute"); @@ -1934,6 +1957,34 @@ mod tests { fs::remove_dir_all(&tmp).ok(); } + // Review r1 f2: a failed `podman ls` surfaces as a preview error instead of + // silently showing nothing. + #[test] + fn podman_preview_surfaces_list_errors() { + use crate::runner::RunError; + let tmp = tempdir("podman-err"); + let runner = Arc::new(FakeRunner::new()); + runner.set_look_path("podman", "/usr/bin/podman"); + runner.set_handler(|spec| { + let args: Vec = spec + .args + .iter() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + if spec.program.to_string_lossy() == "podman" && args.iter().any(|a| a == "container") { + return Err(RunError::Spawn(std::io::Error::other("storage lock"))); + } + Ok(Output::default()) + }); + let deps = deps_for_test(&tmp, runner.clone() as Arc); + let target = podman_target_in(&deps); + + let (items, err) = (target.preview.as_ref().expect("preview"))(); + assert!(items.is_empty(), "items={items:?}"); + assert!(err.is_some(), "preview must propagate ls errors"); + fs::remove_dir_all(&tmp).ok(); + } + // Finding 5 (review p3 f5): the scan recurses `*.wants/`, honors the // configured config home, and reports the real symlink size. #[test] diff --git a/src/optimize.rs b/src/optimize.rs index cadc21f..d04711d 100644 --- a/src/optimize.rs +++ b/src/optimize.rs @@ -981,11 +981,20 @@ fn clean_stale_recursive( } /// True when `p` is live user state the stale-state step must never delete: -/// anything inside a [`LIVE_STATE_DIRS`] subtree, or the file `$HISTFILE` -/// points at. +/// anything inside a [`LIVE_STATE_DIRS`] subtree, a known shell/live-state +/// file name, or the file `$HISTFILE` points at. +/// +/// `HISTFILE` is usually a shell variable (bash/zsh do not export it), so the +/// env var alone is unreliable — the name-based guard is the real protection. fn is_live_state(state_home: &Path, p: &Path, histfile: Option<&Path>) -> bool { + let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default(); + if name == "history" || name == "lesshst" || name.ends_with("_history") { + return true; + } if let Some(hf) = histfile { - if hf == p { + // Compare the raw path and the base name so an unexpanded or + // differently-rooted HISTFILE still protects the same file. + if hf == p || hf.file_name() == p.file_name() { return true; } } @@ -1592,6 +1601,59 @@ mod tests { fs::remove_dir_all(&root).ok(); } + // Review r1 f1: shell history files must be protected by name because + // HISTFILE is a shell variable (not exported) in practice. + #[test] + fn state_clean_skips_history_files_by_name() { + let root = tempdir("opt-state-hist"); + let config_home = root.join("config"); + let state = root.join("state"); + fs::create_dir_all(&config_home).unwrap(); + fs::create_dir_all(state.join("zsh")).unwrap(); + + for name in ["zsh/history", "zsh_history", "lesshst"] { + let p = state.join(name); + fs::write(&p, "history data").unwrap(); + set_mtime_secs_ago(&p, 40 * 86400); + } + let stale = state.join("cache.log"); + fs::write(&stale, "stale").unwrap(); + set_mtime_secs_ago(&stale, 40 * 86400); + + let runner = FakeRunner::new(); + let trash_deps = trash_deps_at(root.join("data-home")); + let mut out = Vec::new(); + state_clean(&runner, &config_home, &state, &trash_deps, &mut out).expect("state clean"); + + for name in ["zsh/history", "zsh_history", "lesshst"] { + assert!(state.join(name).exists(), "{name} must be kept"); + } + assert!(!stale.exists(), "cache.log must be trashed"); + fs::remove_dir_all(&root).ok(); + } + + // Review r1 f1: the HISTFILE path (when present) is honoured, and a + // non-history file with no HISTFILE match is eligible. + #[test] + fn is_live_state_honors_histfile_path() { + let state = Path::new("/h/.local/state"); + let hist = Path::new("/h/.local/state/zsh/history"); + assert!(is_live_state(state, hist, Some(hist)), "HISTFILE path"); + // A differently-rooted HISTFILE still protects the same file name. + assert!( + is_live_state( + state, + Path::new("/h/.local/state/.myhist"), + Some(Path::new("/x/.myhist")) + ), + "same base name as HISTFILE" + ); + assert!( + !is_live_state(state, Path::new("/h/.local/state/other"), None), + "unrelated file is eligible" + ); + } + // Finding 4 (review p3 f4): a symlinked state root is rejected (SafeDelete // cleanup-root invariant). #[test] From 7cad4aaf47c9c4cdc1bf9ced4f398f962e183fb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ba=20R=E1=BB=8Di?= Date: Mon, 19 Oct 2026 08:21:41 +0700 Subject: [PATCH 12/17] fix(purge,installer): resolve Phase 4 review findings 1-4 F1 (HIGH): purge/installer never opened operations.log; add an injectable Deps seam that calls oplog::init_logger_at before any log_outcome, with a test asserting a log line is written. F2 (MEDIUM): generic artifact names (target/dist/build) now require a project manifest in the parent dir; strong names stay name-only. F3 (MEDIUM): installer defaults to ~/Downloads only and the archive heuristic no longer treats any .sh/.bin/.run or setup.py as an installer. F4 (MEDIUM spec gap): interactive multi-select for purge/installer (new src/tui/select.rs) on a TTY, menu entries + dispatch, headless --yes and the non-TTY gate unchanged; all deletions still go through trash::safe_delete. --- src/installer/inspect.rs | 120 +++++++++++++++--- src/installer/mod.rs | 216 +++++++++++++++++++++++++++++--- src/installer/scanner.rs | 40 ++++-- src/purge/mod.rs | 238 ++++++++++++++++++++++++++++++++--- src/purge/scanner.rs | 96 +++++++++++++++ src/tui/menu.rs | 21 +++- src/tui/mod.rs | 33 ++++- src/tui/select.rs | 259 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 964 insertions(+), 59 deletions(-) create mode 100644 src/tui/select.rs diff --git a/src/installer/inspect.rs b/src/installer/inspect.rs index 9218881..4f67dd4 100644 --- a/src/installer/inspect.rs +++ b/src/installer/inspect.rs @@ -7,7 +7,19 @@ use std::time::Duration; use crate::runner::{CommandSpec, ProcessRunner, Runner}; -/// Check if an entry name within an archive indicates an installer or executable binary. +/// Package/container extensions that identify an installer by themselves. +const PACKAGE_EXTS: &[&str] = &["deb", "appimage", "iso", "exe", "pkg", "rpm", "msi"]; + +/// Extensions an `install*`/`setup*` script or binary may carry. +const SCRIPT_EXTS: &[&str] = &["sh", "bin", "run", "exe"]; + +/// Check if an entry name within an archive indicates an installer or +/// executable binary. +/// +/// Review F3: an archive used to count as an installer when *any* entry ended +/// in `.sh`/`.bin`/`.run`, so a project archive holding `build.sh` matched. +/// Now a script entry must itself be an installer (`install*`/`setup*` with a +/// script/binary extension), and Python's `setup.py` no longer matches. pub fn is_installer_entry(name: &str) -> bool { let lower = name.to_ascii_lowercase(); let basename = Path::new(&lower) @@ -15,24 +27,24 @@ pub fn is_installer_entry(name: &str) -> bool { .and_then(|n| n.to_str()) .unwrap_or(&lower); - // Common installer extensions - if basename.ends_with(".deb") - || basename.ends_with(".appimage") - || basename.ends_with(".sh") - || basename.ends_with(".bin") - || basename.ends_with(".run") - || basename.ends_with(".exe") - || basename.ends_with(".pkg") - { - return true; - } + let (stem, ext) = match basename.rsplit_once('.') { + Some((stem, ext)) => (stem, Some(ext)), + None => (basename, None), + }; - // Common installer filenames - if basename.starts_with("install") || basename.starts_with("setup") || basename == "configure" { + // `package.deb`, `app.AppImage`, `setup.exe` are installers by extension. + if let Some(ext) = ext + && PACKAGE_EXTS.contains(&ext) + { return true; } - false + // A bare script/binary (or one with a script extension) named install*/setup*. + let is_script = match ext { + None => true, + Some(ext) => SCRIPT_EXTS.contains(&ext), + }; + is_script && (stem.starts_with("install") || stem.starts_with("setup") || stem == "configure") } /// Inspect a ZIP archive's central directory to determine if it contains installer files. @@ -174,4 +186,82 @@ mod tests { assert!(!is_installer_entry("image.png")); assert!(!is_installer_entry("document.pdf")); } + + // Review F3: a project archive's build scripts and Python's setup.py must + // not make the archive an "installer". + #[test] + fn installer_entry_rejects_build_script_and_python_setup() { + assert!(!is_installer_entry("build.sh")); + assert!(!is_installer_entry("project/scripts/build.sh")); + assert!(!is_installer_entry("setup.py")); + assert!(!is_installer_entry("project/install.md")); + // Real installers still match. + assert!(is_installer_entry("configure")); + assert!(is_installer_entry("project/install.sh")); + assert!(is_installer_entry("nested/setup.run")); + } + + fn tempdir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!( + "mu-test-inspect-{}-{}-{}", + tag, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&d).unwrap(); + d + } + + /// Build a `.tar.gz` fixture with `tar` (the same tool `inspect_targz` + /// shells out to). + fn targz_fixture(dir: &Path, name: &str, entries: &[(&str, &str)]) -> std::path::PathBuf { + let src = dir.join(format!("{name}-src")); + for (rel, body) in entries { + let p = src.join(rel); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(&p, body).unwrap(); + } + let out = dir.join(name); + let status = std::process::Command::new("tar") + .arg("czf") + .arg(&out) + .arg("-C") + .arg(&src) + .arg(".") + .status() + .expect("tar must be available"); + assert!(status.success(), "tar failed for {name}"); + out + } + + // Review F3: a project archive holding `build.sh` is not an installer; + // one holding `install.sh` is. + #[test] + fn installer_ignores_project_archive_with_build_script() { + let dir = tempdir("targz"); + let project = targz_fixture( + &dir, + "backup-2025.tar.gz", + &[("project/build.sh", "#!/bin/sh\nmake\n")], + ); + let real = targz_fixture( + &dir, + "tool-1.0.tar.gz", + &[("tool/install.sh", "#!/bin/sh\ncp app /usr/local/bin\n")], + ); + + assert!( + !inspect_targz(&project), + "a project archive with build.sh must not count as an installer" + ); + assert!( + inspect_targz(&real), + "an archive with install.sh is an installer" + ); + + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/src/installer/mod.rs b/src/installer/mod.rs index da2da33..3f87519 100644 --- a/src/installer/mod.rs +++ b/src/installer/mod.rs @@ -4,14 +4,72 @@ pub mod inspect; pub mod model; pub mod scanner; +use std::path::{Path, PathBuf}; + +use crate::error::Result; use crate::oplog; use crate::size; use crate::trash; +use crate::xdg; pub use model::Options; +/// Interactive selection hook (review F4). `None` = not interactive (non-TTY). +pub type SelectFn = Box Option>>; +/// Deletion hook; production always routes through `trash::safe_delete`. +pub type DeleteFn = Box Result<()>>; + +/// Injectable dependencies for `mu installer` — see `purge::Deps`. +pub struct Deps { + /// XDG data home holding `mu/operations.log`. + pub data_home: PathBuf, + /// `MU_NO_OPLOG=1` disables the operations log. + pub no_oplog: bool, + /// Interactive selection (review F4). + pub select: SelectFn, + /// Deletion seam; production always routes through `trash::safe_delete`. + pub delete: DeleteFn, +} + +impl Deps { + /// The production hooks. + pub fn real() -> Self { + Self { + data_home: xdg::data_home(), + no_oplog: std::env::var_os("MU_NO_OPLOG").as_deref() == Some(std::ffi::OsStr::new("1")), + select: Box::new(|packages| { + let rows: Vec<(String, String)> = packages + .iter() + .map(|p| { + ( + p.path.display().to_string(), + format!("{} {}", p.kind.as_str(), size::human_size(p.size as i64)), + ) + }) + .collect(); + crate::tui::select::select_indices("Select installer packages to remove", rows) + }), + delete: Box::new(|p| trash::safe_delete(p, false)), + } + } +} + /// Entry point for `mu installer`. pub fn run(opts: &Options) -> i32 { + run_in(opts, &Deps::real()) +} + +/// Testable core of [`run`]. +pub(crate) fn run_in(opts: &Options, deps: &Deps) -> i32 { + // Review F1: every destructive command opens the operations log itself — + // `log_outcome` silently no-ops while `LOG_FILE` is closed. + if !deps.no_oplog + && let Err(e) = oplog::init_logger_at(&deps.data_home) + && opts.debug + { + eprintln!("warn: could not open log: {e}"); + } + let roots = scanner::resolve_installer_roots(&opts.paths); if roots.is_empty() { println!("No installer directories found to scan."); @@ -58,17 +116,33 @@ pub fn run(opts: &Options) -> i32 { return 0; } - if !opts.auto_yes { - println!("\nNon-interactive: pass --yes to confirm, or --dry-run to preview."); - println!("Aborted."); - return 0; - } + // Review F4: without `--yes` and on a TTY, let the user pick which + // packages to remove; a non-TTY keeps the headless gate. + let selected: Vec<&model::InstallerPackage> = if opts.auto_yes { + all_packages.iter().collect() + } else { + match (deps.select)(&all_packages) { + None => { + println!("\nNon-interactive: pass --yes to confirm, or --dry-run to preview."); + println!("Aborted."); + return 0; + } + Some(indices) if indices.is_empty() => { + println!("\nNothing selected. Aborted."); + return 0; + } + Some(indices) => indices + .iter() + .filter_map(|&i| all_packages.get(i)) + .collect(), + } + }; - // Execution: route strictly through trash::safe_delete + // Execution: route strictly through the SafeDelete seam. let mut removed_count = 0; let mut freed_bytes = 0; - for p in &all_packages { - match trash::safe_delete(&p.path, false) { + for p in selected { + match (deps.delete)(&p.path) { Ok(()) => { oplog::log_outcome("installer", &p.path.to_string_lossy(), "success"); removed_count += 1; @@ -94,6 +168,7 @@ mod tests { use super::*; use std::fs::{self, File}; use std::path::PathBuf; + use std::sync::{Arc, Mutex}; fn tempdir(tag: &str) -> PathBuf { let d = std::env::temp_dir().join(format!( @@ -109,12 +184,35 @@ mod tests { d } + /// Deps rooted at a temp `data_home` with a no-op delete that records the + /// paths it was asked to remove — no env mutation, no real trash. + fn test_deps(root: &Path, select: SelectFn) -> (Deps, Arc>>) { + let log = Arc::new(Mutex::new(Vec::new())); + let sink = log.clone(); + let deps = Deps { + data_home: root.join("data-home"), + no_oplog: false, + select, + delete: Box::new(move |p| { + sink.lock().unwrap().push(p.to_path_buf()); + Ok(()) + }), + }; + (deps, log) + } + + fn fixture(root: &Path, name: &str) -> PathBuf { + let f = root.join(name); + File::create(&f).unwrap(); + f + } + #[test] fn installer_dry_run_leaves_files_intact() { let root = tempdir("dry"); - let dummy = root.join("dummy.deb"); - File::create(&dummy).unwrap(); + let dummy = fixture(&root, "dummy.deb"); + let (deps, removed) = test_deps(&root, Box::new(|_| panic!("dry run must not select"))); let opts = Options { paths: vec![root.clone()], dry_run: true, @@ -122,19 +220,22 @@ mod tests { debug: false, }; - let code = run(&opts); + let code = run_in(&opts, &deps); assert_eq!(code, 0); assert!(dummy.exists(), "dry run must leave installer file intact"); + assert!(removed.lock().unwrap().is_empty()); + oplog::close_logger(); let _ = fs::remove_dir_all(&root); } #[test] fn installer_aborts_without_auto_yes() { let root = tempdir("abort"); - let dummy = root.join("app.AppImage"); - File::create(&dummy).unwrap(); + let dummy = fixture(&root, "app.AppImage"); + // Non-TTY: the selector reports "not interactive" (None). + let (deps, removed) = test_deps(&root, Box::new(|_| None)); let opts = Options { paths: vec![root.clone()], dry_run: false, @@ -142,10 +243,97 @@ mod tests { debug: false, }; - let code = run(&opts); + let code = run_in(&opts, &deps); assert_eq!(code, 0); assert!(dummy.exists(), "aborted installer must not delete anything"); + assert!(removed.lock().unwrap().is_empty()); + + oplog::close_logger(); + let _ = fs::remove_dir_all(&root); + } + + // Review F1: `mu installer` must open the operations log itself. + #[test] + fn installer_dry_run_writes_operations_log() { + let root = tempdir("log"); + fixture(&root, "dummy.deb"); + + let (deps, _removed) = test_deps(&root, Box::new(|_| None)); + let opts = Options { + paths: vec![root.clone()], + dry_run: true, + auto_yes: false, + debug: false, + }; + + assert_eq!(run_in(&opts, &deps), 0); + oplog::close_logger(); + + let log = fs::read_to_string(deps.data_home.join("mu").join("operations.log")) + .expect("operations.log must be created by installer"); + assert!( + log.contains("installer"), + "missing installer log line: {log:?}" + ); + assert!(log.contains("dry-run"), "missing dry-run outcome: {log:?}"); + + let _ = fs::remove_dir_all(&root); + } + + // Review F4: an interactive selection removes exactly the chosen subset. + #[test] + fn installer_interactive_selection_removes_only_selected() { + let root = tempdir("select"); + let a = fixture(&root, "a.deb"); + let b = fixture(&root, "b.deb"); + + let (deps, removed) = test_deps( + &root, + Box::new(|items| { + assert_eq!(items.len(), 2, "both candidates offered"); + Some(vec![1]) + }), + ); + let opts = Options { + paths: vec![root.clone()], + dry_run: false, + auto_yes: false, + debug: false, + }; + + assert_eq!(run_in(&opts, &deps), 0); + let removed = removed.lock().unwrap().clone(); + assert_eq!( + removed.len(), + 1, + "exactly one package selected: {removed:?}" + ); + let kept = if removed[0] == a { &b } else { &a }; + assert!(kept.exists(), "unselected package must be left alone"); + + oplog::close_logger(); + let _ = fs::remove_dir_all(&root); + } + + // Review F4: selecting nothing removes nothing and exits 0. + #[test] + fn installer_empty_selection_removes_nothing() { + let root = tempdir("none"); + let dummy = fixture(&root, "dummy.deb"); + + let (deps, removed) = test_deps(&root, Box::new(|_| Some(Vec::new()))); + let opts = Options { + paths: vec![root.clone()], + dry_run: false, + auto_yes: false, + debug: false, + }; + + assert_eq!(run_in(&opts, &deps), 0); + assert!(removed.lock().unwrap().is_empty()); + assert!(dummy.exists()); + oplog::close_logger(); let _ = fs::remove_dir_all(&root); } } diff --git a/src/installer/scanner.rs b/src/installer/scanner.rs index 04957cf..80ff400 100644 --- a/src/installer/scanner.rs +++ b/src/installer/scanner.rs @@ -10,6 +10,14 @@ use crate::xdg; /// Resolve roots to scan for installer packages. pub fn resolve_installer_roots(configured: &[PathBuf]) -> Vec { + resolve_installer_roots_in(configured, &xdg::home_dir()) +} + +/// Review F3: the default root is `~/Downloads` only. Downloads is where +/// installers land; Desktop holds apps the user actually runs and Documents +/// holds data, so scanning them turns the archive heuristic into silent data +/// loss. `--paths` still scans an explicit directory (including Desktop). +pub(crate) fn resolve_installer_roots_in(configured: &[PathBuf], home: &Path) -> Vec { if !configured.is_empty() { return configured .iter() @@ -27,13 +35,12 @@ pub fn resolve_installer_roots(configured: &[PathBuf]) -> Vec { .collect(); } - let home = xdg::home_dir(); - let candidates = ["Downloads", "Desktop", "Documents"]; - candidates - .iter() - .map(|d| home.join(d)) - .filter(|p| p.is_dir()) - .collect() + let downloads = home.join("Downloads"); + if downloads.is_dir() { + vec![downloads] + } else { + Vec::new() + } } /// Scan a root directory for installer packages. @@ -162,4 +169,23 @@ mod tests { let _ = fs::remove_dir_all(&root); } + + // Review F3: Downloads is the only default root — Desktop/Documents hold + // apps in use and user data, not installers. + #[test] + fn resolve_installer_roots_defaults_to_downloads_only() { + let root = tempdir("roots"); + for d in ["Downloads", "Desktop", "Documents"] { + fs::create_dir_all(root.join(d)).unwrap(); + } + + let roots = resolve_installer_roots_in(&[], &root); + assert_eq!(roots, vec![root.join("Downloads")]); + + // An explicit `--paths` still scans what the user asked for. + let explicit = resolve_installer_roots_in(&[root.join("Desktop")], &root); + assert_eq!(explicit, vec![root.join("Desktop")]); + + let _ = fs::remove_dir_all(&root); + } } diff --git a/src/purge/mod.rs b/src/purge/mod.rs index 9e534e6..e9e2d85 100644 --- a/src/purge/mod.rs +++ b/src/purge/mod.rs @@ -3,14 +3,77 @@ pub mod model; pub mod scanner; +use std::path::{Path, PathBuf}; + +use crate::error::Result; use crate::oplog; use crate::size; use crate::trash; +use crate::xdg; pub use model::Options; +/// Interactive selection hook (review F4). `None` means "not interactive" +/// (stdout is not a TTY) — the caller then falls back to the headless +/// `--yes` gate. `Some(indices)` is the chosen subset (possibly empty). +pub type SelectFn = Box Option>>; +/// Deletion hook; production always routes through `trash::safe_delete`. +pub type DeleteFn = Box Result<()>>; + +/// Injectable dependencies for `mu purge` — mirrors the `Deps` pattern in +/// `optimize.rs`/`uninstall/mod.rs` so the operations log, the interactive +/// selector, and the deletion path are testable without mutating the +/// environment or the user's real trash. +pub struct Deps { + /// XDG data home holding `mu/operations.log`. + pub data_home: PathBuf, + /// `MU_NO_OPLOG=1` disables the operations log. + pub no_oplog: bool, + /// Interactive selection (review F4). + pub select: SelectFn, + /// Deletion seam; production always routes through `trash::safe_delete`. + pub delete: DeleteFn, +} + +impl Deps { + /// The production hooks. + pub fn real() -> Self { + Self { + data_home: xdg::data_home(), + no_oplog: std::env::var_os("MU_NO_OPLOG").as_deref() == Some(std::ffi::OsStr::new("1")), + select: Box::new(|artifacts| { + let rows: Vec<(String, String)> = artifacts + .iter() + .map(|a| { + ( + a.path.display().to_string(), + format!("{} {}", a.kind, size::human_size(a.size as i64)), + ) + }) + .collect(); + crate::tui::select::select_indices("Select project artifacts to purge", rows) + }), + delete: Box::new(|p| trash::safe_delete(p, false)), + } + } +} + /// Entry point for `mu purge`. pub fn run(opts: &Options) -> i32 { + run_in(opts, &Deps::real()) +} + +/// Testable core of [`run`]. +pub(crate) fn run_in(opts: &Options, deps: &Deps) -> i32 { + // Review F1: every destructive command opens the operations log itself — + // `log_outcome` silently no-ops while `LOG_FILE` is closed. + if !deps.no_oplog + && let Err(e) = oplog::init_logger_at(&deps.data_home) + && opts.debug + { + eprintln!("warn: could not open log: {e}"); + } + let roots = scanner::resolve_scan_roots(&opts.paths); if roots.is_empty() { println!("No project directories found to scan."); @@ -57,17 +120,34 @@ pub fn run(opts: &Options) -> i32 { return 0; } - if !opts.auto_yes { - println!("\nNon-interactive: pass --yes to confirm, or --dry-run to preview."); - println!("Aborted."); - return 0; - } + // Review F4: without `--yes` and on a TTY, let the user pick which + // artifacts to purge (the spec's "interactive selection ... or headless + // removal with --yes"). A non-TTY keeps the headless gate. + let selected: Vec<&model::PurgeArtifact> = if opts.auto_yes { + all_artifacts.iter().collect() + } else { + match (deps.select)(&all_artifacts) { + None => { + println!("\nNon-interactive: pass --yes to confirm, or --dry-run to preview."); + println!("Aborted."); + return 0; + } + Some(indices) if indices.is_empty() => { + println!("\nNothing selected. Aborted."); + return 0; + } + Some(indices) => indices + .iter() + .filter_map(|&i| all_artifacts.get(i)) + .collect(), + } + }; - // Non-dry-run execution: route strictly through trash::safe_delete + // Non-dry-run execution: route strictly through the SafeDelete seam. let mut deleted_count = 0; let mut freed_bytes = 0; - for a in &all_artifacts { - match trash::safe_delete(&a.path, false) { + for a in selected { + match (deps.delete)(&a.path) { Ok(()) => { oplog::log_outcome("purge", &a.path.to_string_lossy(), "success"); deleted_count += 1; @@ -93,6 +173,7 @@ mod tests { use super::*; use std::fs::{self, File}; use std::path::PathBuf; + use std::sync::{Arc, Mutex}; fn tempdir(tag: &str) -> PathBuf { let d = std::env::temp_dir().join(format!( @@ -108,14 +189,39 @@ mod tests { d } + /// Deps rooted at a temp `data_home` with a no-op delete that records the + /// paths it was asked to remove — no env mutation, no real trash. + fn test_deps(root: &Path, select: SelectFn) -> (Deps, Arc>>) { + let log = Arc::new(Mutex::new(Vec::new())); + let sink = log.clone(); + let deps = Deps { + data_home: root.join("data-home"), + no_oplog: false, + select, + delete: Box::new(move |p| { + sink.lock().unwrap().push(p.to_path_buf()); + Ok(()) + }), + }; + (deps, log) + } + + fn marked_project(root: &Path, name: &str, artifact: &str) -> PathBuf { + let proj = root.join(name); + fs::create_dir_all(proj.join(artifact)).unwrap(); + // `target`/`build`/`dist` require a project manifest (review F2). + File::create(proj.join("Cargo.toml")).unwrap(); + File::create(proj.join(artifact).join("output.bin")).unwrap(); + proj + } + #[test] fn purge_dry_run_leaves_files_intact() { let root = tempdir("dry"); - let nm = root.join("node_modules"); - fs::create_dir_all(&nm).unwrap(); - let sentinel = nm.join("sentinel.js"); - File::create(&sentinel).unwrap(); + let proj = marked_project(&root, "proj", "target"); + let sentinel = proj.join("target").join("output.bin"); + let (deps, deleted) = test_deps(&root, Box::new(|_| panic!("dry run must not select"))); let opts = Options { paths: vec![root.clone()], dry_run: true, @@ -124,21 +230,23 @@ mod tests { debug: false, }; - let code = run(&opts); + let code = run_in(&opts, &deps); assert_eq!(code, 0); assert!(sentinel.exists(), "dry run must leave sentinel intact"); + assert!(deleted.lock().unwrap().is_empty()); + oplog::close_logger(); let _ = fs::remove_dir_all(&root); } #[test] fn purge_aborts_without_auto_yes() { let root = tempdir("abort"); - let nm = root.join("target"); - fs::create_dir_all(&nm).unwrap(); - let sentinel = nm.join("output.bin"); - File::create(&sentinel).unwrap(); + let proj = marked_project(&root, "proj", "target"); + let sentinel = proj.join("target").join("output.bin"); + // Non-TTY: the selector reports "not interactive" (None). + let (deps, deleted) = test_deps(&root, Box::new(|_| None)); let opts = Options { paths: vec![root.clone()], dry_run: false, @@ -147,10 +255,104 @@ mod tests { debug: false, }; - let code = run(&opts); + let code = run_in(&opts, &deps); assert_eq!(code, 0); assert!(sentinel.exists(), "aborted purge must not delete anything"); + assert!(deleted.lock().unwrap().is_empty()); + + oplog::close_logger(); + let _ = fs::remove_dir_all(&root); + } + + // Review F1: a destructive command must open the operations log itself; + // the dry-run path proves it because `log_outcome` writes only while + // `LOG_FILE` is open. + #[test] + fn purge_dry_run_writes_operations_log() { + let root = tempdir("log"); + marked_project(&root, "proj", "target"); + + let (deps, _deleted) = test_deps(&root, Box::new(|_| None)); + let opts = Options { + paths: vec![root.clone()], + dry_run: true, + auto_yes: false, + include_empty: false, + debug: false, + }; + + assert_eq!(run_in(&opts, &deps), 0); + oplog::close_logger(); + + let log = fs::read_to_string(deps.data_home.join("mu").join("operations.log")) + .expect("operations.log must be created by purge"); + assert!(log.contains("purge"), "missing purge log line: {log:?}"); + assert!(log.contains("dry-run"), "missing dry-run outcome: {log:?}"); + + let _ = fs::remove_dir_all(&root); + } + + // Review F4: an interactive selection purges exactly the chosen subset + // (and only it), still one logged SafeDelete per path. + #[test] + fn purge_interactive_selection_deletes_only_selected() { + let root = tempdir("select"); + marked_project(&root, "proj-a", "target"); + marked_project(&root, "proj-b", "target"); + + let (deps, deleted) = test_deps( + &root, + Box::new(|items| { + assert_eq!(items.len(), 2, "both candidates offered"); + Some(vec![0]) + }), + ); + let opts = Options { + paths: vec![root.clone()], + dry_run: false, + auto_yes: false, + include_empty: false, + debug: false, + }; + + assert_eq!(run_in(&opts, &deps), 0); + let deleted = deleted.lock().unwrap().clone(); + assert_eq!( + deleted.len(), + 1, + "exactly one artifact selected: {deleted:?}" + ); + let mut others = vec![root.join("proj-a/target"), root.join("proj-b/target")]; + others.retain(|p| p != &deleted[0]); + assert!( + others[0].exists(), + "the unselected artifact must be left alone" + ); + + oplog::close_logger(); + let _ = fs::remove_dir_all(&root); + } + + // Review F4: selecting nothing deletes nothing and exits 0. + #[test] + fn purge_empty_selection_deletes_nothing() { + let root = tempdir("none"); + let proj = marked_project(&root, "proj", "target"); + + let (deps, deleted) = test_deps(&root, Box::new(|_| Some(Vec::new()))); + let opts = Options { + paths: vec![root.clone()], + dry_run: false, + auto_yes: false, + include_empty: false, + debug: false, + }; + + assert_eq!(run_in(&opts, &deps), 0); + assert!(deleted.lock().unwrap().is_empty()); + assert!(proj.join("target").exists()); + oplog::close_logger(); let _ = fs::remove_dir_all(&root); } } diff --git a/src/purge/scanner.rs b/src/purge/scanner.rs index c9f03b8..2f4c836 100644 --- a/src/purge/scanner.rs +++ b/src/purge/scanner.rs @@ -22,6 +22,46 @@ pub const BUILD_DIR_NAMES: &[&str] = &[ ".next", ]; +/// Names that are unambiguous build artifacts by name alone. +pub const STRONG_BUILD_DIR_NAMES: &[&str] = &[ + "node_modules", + ".venv", + "__pycache__", + ".pytest_cache", + ".tox", + ".gradle", + ".next", +]; + +/// Generic build-output names that only count as artifacts next to a project +/// manifest — otherwise they are ordinary source directories (review F2). +pub const GENERIC_BUILD_DIR_NAMES: &[&str] = &["target", "dist", "build"]; + +/// Manifests that mark the parent directory as a project root. +pub const PROJECT_MARKERS: &[&str] = &[ + "package.json", + "Cargo.toml", + "pyproject.toml", + "setup.py", + "setup.cfg", + "requirements.txt", + "go.mod", + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts", + "pom.xml", + "Makefile", +]; + +/// True when `artifact`'s parent looks like a project root (review F2). +fn has_project_marker(artifact: &Path) -> bool { + match artifact.parent() { + Some(parent) => PROJECT_MARKERS.iter().any(|m| parent.join(m).is_file()), + None => false, + } +} + /// Resolve scan roots. If `configured` is non-empty, use those. /// Otherwise, check `$XDG_CONFIG_HOME/mu/purge_paths`. /// If absent, fall back to standard project directories under `$HOME`. @@ -132,6 +172,14 @@ fn walk_dir( } if BUILD_DIR_NAMES.contains(&name) { + // Review F2: generic names (`target`/`dist`/`build`) only count + // when the parent carries a project manifest; a directory that + // merely shares the name (e.g. `tool/src/build`) is ordinary + // source, so recurse instead of reporting it. + if GENERIC_BUILD_DIR_NAMES.contains(&name) && !has_project_marker(&path) { + walk_dir(root, &path, include_empty, depth + 1, artifacts); + continue; + } // Validate candidate safety boundary if paths::validate_cleanup_candidate(root, &path).is_ok() { let empty = is_dir_empty(&path); @@ -200,6 +248,8 @@ mod tests { let proj = root.join("empty-project"); let dist = proj.join("dist"); fs::create_dir_all(&dist).unwrap(); + // `dist` is a generic name — it needs a project manifest (review F2). + File::create(proj.join("package.json")).unwrap(); let without_empty = scan_root(&root, false); assert!(without_empty.is_empty()); @@ -211,6 +261,52 @@ mod tests { let _ = fs::remove_dir_all(&root); } + // Review F2: `build`/`dist`/`target` without a project manifest in the + // parent are ordinary source directories, not build artifacts. + #[test] + fn scan_skips_generic_names_without_project_marker() { + let root = tempdir("marker-none"); + // A Go/Python source package literally named `build` under `src/`. + fs::create_dir_all(root.join("tool/src/build")).unwrap(); + File::create(root.join("tool/src/build/pkg.go")).unwrap(); + // Committed release assets named `dist/` with no manifest above it. + fs::create_dir_all(root.join("site/dist/release")).unwrap(); + File::create(root.join("site/dist/release/asset.js")).unwrap(); + // A plain `target/` holder with no build system. + fs::create_dir_all(root.join("notes/target")).unwrap(); + File::create(root.join("notes/target/scratch.txt")).unwrap(); + + let artifacts = scan_root(&root, false); + assert!( + artifacts.is_empty(), + "unmarked generic dirs must be skipped: {artifacts:?}" + ); + + let _ = fs::remove_dir_all(&root); + } + + // Review F2: the same generic names ARE artifacts next to their manifest. + #[test] + fn scan_finds_generic_names_with_parent_marker() { + let root = tempdir("marker-yes"); + fs::create_dir_all(root.join("rustproj/target/debug")).unwrap(); + File::create(root.join("rustproj/Cargo.toml")).unwrap(); + fs::create_dir_all(root.join("gradleproj/build/libs")).unwrap(); + File::create(root.join("gradleproj/build.gradle")).unwrap(); + fs::create_dir_all(root.join("jsproj/dist")).unwrap(); + File::create(root.join("jsproj/package.json")).unwrap(); + File::create(root.join("jsproj/dist/bundle.js")).unwrap(); + + let artifacts = scan_root(&root, false); + let kinds: Vec<&str> = artifacts.iter().map(|a| a.kind.as_str()).collect(); + assert_eq!(artifacts.len(), 3, "got {kinds:?}"); + for name in ["target", "build", "dist"] { + assert!(kinds.contains(&name), "missing {name}: {kinds:?}"); + } + + let _ = fs::remove_dir_all(&root); + } + #[test] fn scan_skips_symlinks() { let root = tempdir("symlink"); diff --git a/src/tui/menu.rs b/src/tui/menu.rs index 9b8d918..1c15c4a 100644 --- a/src/tui/menu.rs +++ b/src/tui/menu.rs @@ -25,7 +25,8 @@ pub struct MenuItem { pub command: &'static str, } -/// The six menu items — exact order from Go's `menuItems`. +/// The menu items — order from Go's `menuItems`, extended with the Phase 4 +/// `purge`/`installer` commands before Quit. pub const MENU_ITEMS: &[MenuItem] = &[ MenuItem { label: "Audit", @@ -52,6 +53,16 @@ pub const MENU_ITEMS: &[MenuItem] = &[ desc: "Live system dashboard", command: "status", }, + MenuItem { + label: "Purge", + desc: "Clean heavy project build artifacts (node_modules, target)", + command: "purge", + }, + MenuItem { + label: "Installer", + desc: "Find and remove downloaded installer packages", + command: "installer", + }, MenuItem { label: "Quit", desc: "Exit mu", @@ -168,7 +179,7 @@ impl MainMenu { } false } - KeyCode::Char(c @ '1'..='6') => { + KeyCode::Char(c @ '1'..='8') => { let idx = (c as u8 - b'1') as usize; if idx < MENU_ITEMS.len() { self.chosen = Some(MENU_ITEMS[idx].command); @@ -307,9 +318,11 @@ mod tests { #[test] fn menu_items_order() { - assert_eq!(MENU_ITEMS.len(), 6); + assert_eq!(MENU_ITEMS.len(), 8); assert_eq!(MENU_ITEMS[0].command, "audit"); - assert_eq!(MENU_ITEMS[5].command, "quit"); + assert_eq!(MENU_ITEMS[5].command, "purge"); + assert_eq!(MENU_ITEMS[6].command, "installer"); + assert_eq!(MENU_ITEMS[7].command, "quit"); } #[test] diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 3fd297e..0be5403 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -19,6 +19,7 @@ pub mod clean; pub mod confirm; pub mod menu; pub mod run; +pub mod select; pub mod status; pub mod styles; pub mod uninstall; @@ -461,6 +462,36 @@ fn dispatch_subcommand(command: &str, debug: bool) -> anyhow::Result { + // Purge TTY: the interactive multi-select drives the deletion + // (review F4); every removal still goes through SafeDelete. + let opts = crate::purge::Options { + paths: Vec::new(), + dry_run: false, + auto_yes: false, + include_empty: false, + debug, + }; + let code = crate::purge::run(&opts); + if code != 0 { + anyhow::bail!("purge exited with code {code}"); + } + Ok(None) + } + "installer" => { + // Installer TTY: same interactive selection flow as purge. + let opts = crate::installer::Options { + paths: Vec::new(), + dry_run: false, + auto_yes: false, + debug, + }; + let code = crate::installer::run(&opts); + if code != 0 { + anyhow::bail!("installer exited with code {code}"); + } + Ok(None) + } _ => Ok(None), } } @@ -527,7 +558,7 @@ mod tests { #[test] fn menu_items_count() { - assert_eq!(MENU_ITEMS.len(), 6); + assert_eq!(MENU_ITEMS.len(), 8); } #[test] diff --git a/src/tui/select.rs b/src/tui/select.rs new file mode 100644 index 0000000..3c76f5c --- /dev/null +++ b/src/tui/select.rs @@ -0,0 +1,259 @@ +//! Multi-select list — used by `mu purge`/`mu installer` to let the user pick +//! which discovered items to remove (Phase 4 review F4: the spec's +//! "interactive selection in TUI or headless removal with `--yes`"). +//! +//! Keys: ↑/↓ or j/k navigate, Space toggle, `a` select all, `n` select none, +//! Enter confirm, q/Esc/ctrl+c cancel. Nothing is selected by default — the +//! action is destructive. + +use std::io::IsTerminal; + +use crossterm::event::{self, Event, KeyCode, KeyModifiers}; +use crossterm::execute; +use crossterm::terminal::{EnterAlternateScreen, enable_raw_mode}; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::Rect; +use ratatui::text::{Line, Text}; +use ratatui::widgets::Paragraph; + +use super::{AltScreenGuard, RawModeGuard, is_ctrl_c}; + +/// A row is `(label, detail)` — the path and its size/kind. +pub type Row = (String, String); + +/// Multi-select dialog state. +#[derive(Clone, Debug)] +pub struct SelectList { + pub title: String, + pub rows: Vec, + pub cursor: usize, + pub selected: Vec, + pub done: bool, + /// `Some(indices)` after Enter, `None` after cancel/quit. + pub confirmed: Option>, +} + +impl SelectList { + /// Create a list with nothing selected (destructive default). + pub fn new(title: impl Into, rows: Vec) -> Self { + let selected = vec![false; rows.len()]; + Self { + title: title.into(), + rows, + cursor: 0, + selected, + done: false, + confirmed: None, + } + } + + /// Indices currently selected, in list order. + pub fn chosen(&self) -> Vec { + self.selected + .iter() + .enumerate() + .filter(|(_, on)| **on) + .map(|(i, _)| i) + .collect() + } + + fn set_all(&mut self, on: bool) { + self.selected.fill(on); + } + + /// Handle a bare key. Returns true when the dialog is done. + pub fn handle_key(&mut self, key: KeyCode) -> bool { + if self.rows.is_empty() { + self.confirmed = Some(Vec::new()); + self.done = true; + return true; + } + match key { + KeyCode::Up | KeyCode::Char('k') => { + if self.cursor > 0 { + self.cursor -= 1; + } + false + } + KeyCode::Down | KeyCode::Char('j') => { + if self.cursor + 1 < self.rows.len() { + self.cursor += 1; + } + false + } + KeyCode::Char(' ') => { + self.selected[self.cursor] = !self.selected[self.cursor]; + false + } + KeyCode::Char('a') => { + self.set_all(true); + false + } + KeyCode::Char('n') => { + self.set_all(false); + false + } + KeyCode::Enter => { + self.confirmed = Some(self.chosen()); + self.done = true; + true + } + KeyCode::Char('q') | KeyCode::Esc => { + self.confirmed = None; + self.done = true; + true + } + _ => false, + } + } + + /// Handle a key event (ctrl+c quits; modified keys are ignored). + pub fn handle_key_event(&mut self, key: crossterm::event::KeyEvent) -> bool { + if is_ctrl_c(&key) { + self.confirmed = None; + self.done = true; + return true; + } + if key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) + { + return false; + } + self.handle_key(key.code) + } + + /// Render the list into `area`. + pub fn render(&self, frame: &mut ratatui::Frame, area: Rect) { + let mut lines: Vec = Vec::new(); + lines.push(Line::raw("")); + lines.push(Line::raw(format!(" {}", self.title))); + lines.push(Line::raw("")); + + // Keep the cursor visible in a window that fits the area. + let visible = area.height.saturating_sub(6).max(3) as usize; + let start = if self.cursor >= visible { + self.cursor + 1 - visible + } else { + 0 + }; + for (i, (label, detail)) in self.rows.iter().enumerate().skip(start).take(visible) { + let mark = if self.selected[i] { "[x]" } else { "[ ]" }; + let pointer = if i == self.cursor { "▶" } else { " " }; + lines.push(Line::raw(format!(" {pointer} {mark} {label} {detail}"))); + } + lines.push(Line::raw("")); + lines.push(Line::raw( + " ↑/↓ or j/k navigate • Space toggle • a: all • n: none", + )); + lines.push(Line::raw(" Enter: confirm • q/Esc: cancel")); + + frame.render_widget(Paragraph::new(Text::from(lines)), area); + } +} + +/// Interactive multi-select on a TTY. Returns `Some(indices)` on confirm +/// (possibly empty), `None` when the list is cancelled or stdout is not a +/// terminal (the caller then falls back to the headless `--yes` gate). +pub fn select_indices(title: &str, rows: Vec) -> Option> { + if !std::io::stdout().is_terminal() { + return None; + } + if rows.is_empty() { + return Some(Vec::new()); + } + if enable_raw_mode().is_err() { + return None; + } + let _raw_guard = RawModeGuard; + let mut stdout = std::io::stdout(); + if execute!(stdout, EnterAlternateScreen).is_err() { + return None; + } + let _alt_guard = AltScreenGuard; + let backend = CrosstermBackend::new(stdout); + let Ok(mut terminal) = Terminal::new(backend) else { + return None; + }; + + let mut list = SelectList::new(title, rows); + loop { + if terminal.draw(|f| list.render(f, f.area())).is_err() { + return None; + } + let ev = match event::read() { + Ok(ev) => ev, + Err(_) => return None, + }; + if let Event::Key(key) = ev + && list.handle_key_event(key) + { + break; + } + } + list.confirmed +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rows(n: usize) -> Vec { + (0..n) + .map(|i| (format!("/tmp/item-{i}"), format!("{} B", i + 1))) + .collect() + } + + #[test] + fn select_toggle_and_confirm() { + let mut l = SelectList::new("pick", rows(3)); + assert_eq!( + l.chosen(), + Vec::::new(), + "nothing selected initially" + ); + l.handle_key(KeyCode::Char(' ')); // select row 0 + l.handle_key(KeyCode::Down); + l.handle_key(KeyCode::Down); + l.handle_key(KeyCode::Char(' ')); // select row 2 + assert_eq!(l.chosen(), vec![0, 2]); + assert!(l.handle_key(KeyCode::Enter)); + assert_eq!(l.confirmed, Some(vec![0, 2])); + } + + #[test] + fn select_all_and_none() { + let mut l = SelectList::new("pick", rows(4)); + l.handle_key(KeyCode::Char('a')); + assert_eq!(l.chosen(), vec![0, 1, 2, 3]); + l.handle_key(KeyCode::Char('n')); + assert_eq!(l.chosen(), Vec::::new()); + } + + #[test] + fn select_cancel_returns_none() { + let mut l = SelectList::new("pick", rows(2)); + l.handle_key(KeyCode::Char(' ')); + assert!(l.handle_key(KeyCode::Char('q'))); + assert_eq!(l.confirmed, None); + assert!(l.done); + } + + #[test] + fn select_moves_cursor_within_bounds() { + let mut l = SelectList::new("pick", rows(2)); + l.handle_key(KeyCode::Up); // no-op at the top + assert_eq!(l.cursor, 0); + l.handle_key(KeyCode::Down); + l.handle_key(KeyCode::Down); // no-op at the bottom + assert_eq!(l.cursor, 1); + } + + #[test] + fn select_empty_list_confirms_empty() { + let mut l = SelectList::new("pick", Vec::new()); + assert!(l.handle_key(KeyCode::Enter)); + assert_eq!(l.confirmed, Some(Vec::new())); + } +} From a6ae2828c3cf1b4cd6a2fb4a0c8c9db1bb55c2c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ba=20R=E1=BB=8Di?= Date: Mon, 19 Oct 2026 09:54:56 +0700 Subject: [PATCH 13/17] fix(status,cli): resolve Phase 5 review findings 1-5 (process ranking, CPU%, duration parsing, page size, disk partitions) --- src/cli.rs | 64 +++++++++++++------ src/status/proc.rs | 135 +++++++++++++++++++++++++++++++++------ src/tui/status.rs | 9 +-- tests/cli_integration.rs | 14 ++++ 4 files changed, 178 insertions(+), 44 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 467ba5c..462a9ef 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -884,28 +884,32 @@ fn parse_duration(s: &str) -> Option { return None; } if let Some(ms_str) = s.strip_suffix("ms") { - ms_str - .parse::() - .ok() - .map(std::time::Duration::from_millis) + let ms = ms_str.parse::().ok()?; + if ms > 0 { + Some(std::time::Duration::from_millis(ms)) + } else { + None + } } else if let Some(s_str) = s.strip_suffix('s') { - s_str.parse::().ok().and_then(|v| { - if v >= 0.0 { - Some(std::time::Duration::from_secs_f64(v)) - } else { - None - } - }) + let v = s_str.parse::().ok()?; + if v > 0.0 && v.is_finite() { + std::time::Duration::try_from_secs_f64(v).ok() + } else { + None + } } else if let Some(m_str) = s.strip_suffix('m') { - m_str.parse::().ok().and_then(|v| { - if v >= 0.0 { - Some(std::time::Duration::from_secs_f64(v * 60.0)) - } else { - None - } - }) + let v = m_str.parse::().ok()?; + if v > 0.0 && v.is_finite() { + std::time::Duration::try_from_secs_f64(v * 60.0).ok() + } else { + None + } } else if let Ok(secs) = s.parse::() { - Some(std::time::Duration::from_secs(secs)) + if secs > 0 { + Some(std::time::Duration::from_secs(secs)) + } else { + None + } } else { None } @@ -2328,5 +2332,27 @@ mod tests { ); assert_eq!(parse_duration("5"), Some(std::time::Duration::from_secs(5))); assert_eq!(parse_duration("bogus"), None); + assert_eq!(parse_duration("0s"), None); + assert_eq!(parse_duration("0ms"), None); + assert_eq!(parse_duration("0"), None); + assert_eq!(parse_duration("infs"), None); + assert_eq!(parse_duration("-1s"), None); + assert_eq!(parse_duration("1e300s"), None); + } + + #[test] + fn status_interval_flag_zero_and_infinite_rejected() { + for arg in ["0s", "0ms", "0", "infs", "1e300s"] { + match parse(&args(&["status", "--interval", arg])) { + Outcome::Stderr { text, code } => { + assert_eq!(code, 1); + assert!( + text.contains(&format!("invalid duration \"{arg}\"")), + "for {arg}: {text}" + ); + } + _ => panic!("expected Stderr for {arg}"), + } + } } } diff --git a/src/status/proc.rs b/src/status/proc.rs index 19af405..62e3bdb 100644 --- a/src/status/proc.rs +++ b/src/status/proc.rs @@ -557,6 +557,11 @@ pub fn read_disk_io_from(reader: impl BufRead) -> (BTreeMap Result e, Err(_) => return Ok(Vec::new()), }; + let uptime_secs = std::fs::read_to_string(proc_dir.join("uptime")) + .or_else(|_| std::fs::read_to_string("/proc/uptime")) + .ok() + .and_then(|s| { + s.split_whitespace() + .next() + .and_then(|v| v.parse::().ok()) + }) + .unwrap_or(0.0); + + let clk_tck = nix::unistd::sysconf(nix::unistd::SysconfVar::CLK_TCK) + .ok() + .flatten() + .map(|v| v as f64) + .unwrap_or(100.0); + let page_size = nix::unistd::sysconf(nix::unistd::SysconfVar::PAGE_SIZE) + .ok() + .flatten() + .map(|v| v as u64) + .unwrap_or(4096); + let mut procs = Vec::new(); for entry in entries.flatten() { let file_name = entry.file_name(); @@ -768,23 +794,44 @@ pub fn read_top_processes_from(proc_dir: &Path, limit: usize) -> Result 21 { let utime = fields[11].parse::().unwrap_or(0); let stime = fields[12].parse::().unwrap_or(0); + let starttime = fields[19].parse::().unwrap_or(0); let rss_pages = fields[21].parse::().unwrap_or(0); - let total_ticks = utime + stime; - let memory_bytes = rss_pages * 4096; - procs.push(( - total_ticks, - ProcessInfo { - pid, - name: proc_name.to_string(), - cpu_percent: 0.0, - memory_bytes, - }, - )); + let memory_bytes = rss_pages * page_size; + + let cpu_percent = if uptime_secs > 0.0 { + let start_secs = starttime as f64 / clk_tck; + let proc_age_secs = uptime_secs - start_secs; + if proc_age_secs > 0.05 { + let cpu_secs = (utime + stime) as f64 / clk_tck; + let pct = (cpu_secs / proc_age_secs) * 100.0; + (pct * 10.0).round() / 10.0 + } else { + 0.0 + } + } else { + 0.0 + }; + + procs.push(ProcessInfo { + pid, + name: proc_name.to_string(), + cpu_percent, + memory_bytes, + }); } } - procs.sort_by_key(|a| std::cmp::Reverse(a.0)); + procs.sort_by(|a, b| { + b.memory_bytes + .cmp(&a.memory_bytes) + .then_with(|| { + b.cpu_percent + .partial_cmp(&a.cpu_percent) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .then_with(|| a.pid.cmp(&b.pid)) + }); procs.truncate(limit); - Ok(procs.into_iter().map(|(_, p)| p).collect()) + Ok(procs) } #[cfg(test)] @@ -1358,21 +1405,67 @@ cpu cores : 2 std::fs::create_dir_all(&p1).unwrap(); std::fs::create_dir_all(&p2).unwrap(); - // PID 101: comm with space "proc one", utime=10, stime=20 -> total=30, rss=500 - let stat1 = "101 (proc one) S 1 101 101 0 -1 4194304 100 0 0 0 10 20 0 0 20 0 1 0 1234 10000 500 18446744073709551615 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0"; - // PID 102: comm "proc_two", utime=100, stime=50 -> total=150, rss=200 - let stat2 = "102 (proc_two) R 1 102 102 0 -1 4194304 100 0 0 0 100 50 0 0 20 0 1 0 1234 10000 200 18446744073709551615 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0"; + // PID 101: comm with space "proc one", utime=10, stime=20 -> total=30, rss=500, starttime=1000 + let stat1 = "101 (proc one) S 1 101 101 0 -1 4194304 100 0 0 0 10 20 0 0 20 0 1 0 1000 10000 500 18446744073709551615 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0"; + // PID 102: comm "proc_two", utime=100, stime=50 -> total=150, rss=200, starttime=1000 + let stat2 = "102 (proc_two) R 1 102 102 0 -1 4194304 100 0 0 0 100 50 0 0 20 0 1 0 1000 10000 200 18446744073709551615 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0"; std::fs::write(p1.join("stat"), stat1).unwrap(); std::fs::write(p2.join("stat"), stat2).unwrap(); + std::fs::write(temp_dir.join("uptime"), "100.0 200.0\n").unwrap(); let procs = read_top_processes_from(&temp_dir, 5).unwrap(); let _ = std::fs::remove_dir_all(&temp_dir); assert_eq!(procs.len(), 2); - assert_eq!(procs[0].pid, 102); // 150 ticks > 30 ticks - assert_eq!(procs[0].name, "proc_two"); - assert_eq!(procs[1].pid, 101); - assert_eq!(procs[1].name, "proc one"); + // PID 101 has rss=500, which has higher memory than PID 102 rss=200 + assert_eq!(procs[0].pid, 101); + assert_eq!(procs[0].name, "proc one"); + assert!(procs[0].memory_bytes > 0); + assert!(procs[0].cpu_percent > 0.0); + assert_eq!(procs[1].pid, 102); + assert_eq!(procs[1].name, "proc_two"); + assert!(procs[1].memory_bytes > 0); + assert!(procs[1].cpu_percent > 0.0); + } + + #[test] + fn read_battery_parses_capacity_status_and_health() { + let temp_dir = std::env::temp_dir().join(format!("mu_bat_test_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&temp_dir); + let bat0 = temp_dir.join("BAT0"); + std::fs::create_dir_all(&bat0).unwrap(); + + std::fs::write(bat0.join("capacity"), "85\n").unwrap(); + std::fs::write(bat0.join("status"), "Discharging\n").unwrap(); + std::fs::write(bat0.join("energy_full"), "4500000\n").unwrap(); + std::fs::write(bat0.join("energy_full_design"), "5000000\n").unwrap(); + + let bat = read_battery_from(&temp_dir).unwrap().expect("battery found"); + assert_eq!(bat.percentage, 85); + assert_eq!(bat.status, "Discharging"); + assert_eq!(bat.health_percent, Some(90.0)); + + let _ = std::fs::remove_dir_all(&temp_dir); + } + + #[test] + fn read_battery_charge_fallback() { + let temp_dir = std::env::temp_dir().join(format!("mu_bat_fb_test_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&temp_dir); + let bat1 = temp_dir.join("BAT1"); + std::fs::create_dir_all(&bat1).unwrap(); + + std::fs::write(bat1.join("capacity"), "95\n").unwrap(); + std::fs::write(bat1.join("status"), "Charging\n").unwrap(); + std::fs::write(bat1.join("charge_full"), "3800000\n").unwrap(); + std::fs::write(bat1.join("charge_full_design"), "4000000\n").unwrap(); + + let bat = read_battery_from(&temp_dir).unwrap().expect("battery found"); + assert_eq!(bat.percentage, 95); + assert_eq!(bat.status, "Charging"); + assert_eq!(bat.health_percent, Some(95.0)); + + let _ = std::fs::remove_dir_all(&temp_dir); } } diff --git a/src/tui/status.rs b/src/tui/status.rs index bc8ff78..394c809 100644 --- a/src/tui/status.rs +++ b/src/tui/status.rs @@ -253,14 +253,15 @@ impl StatusDashboard { lines.push(Line::raw("")); lines.push(Line::styled(" Top Processes", styles::faint())); lines.push(Line::raw(format!( - " {:<7} {:<24} {:>10}", - "PID", "NAME", "MEM" + " {:<7} {:<20} {:>7} {:>10}", + "PID", "NAME", "CPU%", "MEM" ))); for p in &m.processes { lines.push(Line::raw(format!( - " {:<7} {:<24} {:>10}", + " {:<7} {:<20} {:>6.1}% {:>10}", p.pid, - truncate_width(&p.name, 24), + truncate_width(&p.name, 20), + p.cpu_percent, human_bytes(p.memory_bytes), ))); } diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 79930c5..89374de 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -117,6 +117,20 @@ fn status_help_output() { assert_eq!(stdout(&o), include_str!("golden/help-status.txt")); } +/// `mu status --interval 0s` and `infs` are rejected with exit code 1. +#[test] +fn status_interval_invalid_rejected() { + for arg in ["0s", "0ms", "0", "infs", "1e300s"] { + let o = mu(&["status", "--interval", arg]); + assert_eq!(o.status.code(), Some(1), "for {arg}"); + assert!( + stderr(&o).contains(&format!("invalid duration \"{arg}\"")), + "for {arg}: {}", + stderr(&o) + ); + } +} + /// `mu audit` piped → human report on stdout (Go falls back to report /// mode), exit code is the severity-driven 0/1/2 — never the old stub's 2+ /// or an arg error. From 213c5f74588c12e22c9ee9abbdaf22a4124bc46a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ba=20R=E1=BB=8Di?= Date: Mon, 19 Oct 2026 10:08:05 +0700 Subject: [PATCH 14/17] fix(cli): reject sub-nanosecond zero durations and fix rustfmt chaining --- src/cli.rs | 12 +++++++++--- src/status/proc.rs | 8 ++++++-- tests/cli_integration.rs | 2 +- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 462a9ef..4b0bd2b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -893,14 +893,18 @@ fn parse_duration(s: &str) -> Option { } else if let Some(s_str) = s.strip_suffix('s') { let v = s_str.parse::().ok()?; if v > 0.0 && v.is_finite() { - std::time::Duration::try_from_secs_f64(v).ok() + std::time::Duration::try_from_secs_f64(v) + .ok() + .filter(|d| !d.is_zero()) } else { None } } else if let Some(m_str) = s.strip_suffix('m') { let v = m_str.parse::().ok()?; if v > 0.0 && v.is_finite() { - std::time::Duration::try_from_secs_f64(v * 60.0).ok() + std::time::Duration::try_from_secs_f64(v * 60.0) + .ok() + .filter(|d| !d.is_zero()) } else { None } @@ -2338,11 +2342,13 @@ mod tests { assert_eq!(parse_duration("infs"), None); assert_eq!(parse_duration("-1s"), None); assert_eq!(parse_duration("1e300s"), None); + assert_eq!(parse_duration("1e-10s"), None); + assert_eq!(parse_duration("1e-12m"), None); } #[test] fn status_interval_flag_zero_and_infinite_rejected() { - for arg in ["0s", "0ms", "0", "infs", "1e300s"] { + for arg in ["0s", "0ms", "0", "infs", "1e300s", "1e-10s", "1e-12m"] { match parse(&args(&["status", "--interval", arg])) { Outcome::Stderr { text, code } => { assert_eq!(code, 1); diff --git a/src/status/proc.rs b/src/status/proc.rs index 62e3bdb..7520344 100644 --- a/src/status/proc.rs +++ b/src/status/proc.rs @@ -1441,7 +1441,9 @@ cpu cores : 2 std::fs::write(bat0.join("energy_full"), "4500000\n").unwrap(); std::fs::write(bat0.join("energy_full_design"), "5000000\n").unwrap(); - let bat = read_battery_from(&temp_dir).unwrap().expect("battery found"); + let bat = read_battery_from(&temp_dir) + .unwrap() + .expect("battery found"); assert_eq!(bat.percentage, 85); assert_eq!(bat.status, "Discharging"); assert_eq!(bat.health_percent, Some(90.0)); @@ -1461,7 +1463,9 @@ cpu cores : 2 std::fs::write(bat1.join("charge_full"), "3800000\n").unwrap(); std::fs::write(bat1.join("charge_full_design"), "4000000\n").unwrap(); - let bat = read_battery_from(&temp_dir).unwrap().expect("battery found"); + let bat = read_battery_from(&temp_dir) + .unwrap() + .expect("battery found"); assert_eq!(bat.percentage, 95); assert_eq!(bat.status, "Charging"); assert_eq!(bat.health_percent, Some(95.0)); diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 89374de..dbd73b9 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -120,7 +120,7 @@ fn status_help_output() { /// `mu status --interval 0s` and `infs` are rejected with exit code 1. #[test] fn status_interval_invalid_rejected() { - for arg in ["0s", "0ms", "0", "infs", "1e300s"] { + for arg in ["0s", "0ms", "0", "infs", "1e300s", "1e-10s", "1e-12m"] { let o = mu(&["status", "--interval", arg]); assert_eq!(o.status.code(), Some(1), "for {arg}"); assert!( From b2d42afb29295842fdf83e9b5ca8dba10c3e5d88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ba=20R=E1=BB=8Di?= Date: Mon, 19 Oct 2026 10:37:32 +0700 Subject: [PATCH 15/17] fix(mgmt): resolve Phase 6 review findings 1-10 (whitelist, sudo pam, update, remove) --- docs/plans/active/mole-parity.md | 2 +- src/cli.rs | 16 +- src/config.rs | 100 ++++++++- src/remove.rs | 110 ++++++++-- src/sudo.rs | 360 +++++++++++++++++++++++++------ src/tui/menu.rs | 1 + src/tui/whitelist.rs | 81 ++++++- src/update.rs | 154 +++++++++++-- tests/cli_integration.rs | 50 ++++- 9 files changed, 756 insertions(+), 118 deletions(-) diff --git a/docs/plans/active/mole-parity.md b/docs/plans/active/mole-parity.md index aa55870..9fe3bba 100644 --- a/docs/plans/active/mole-parity.md +++ b/docs/plans/active/mole-parity.md @@ -431,7 +431,7 @@ graph TD - **`mu update` (Self-Update):** - Queries GitHub Releases API for `https://api.github.com/repos/.../releases/latest`. - Downloads architecture-matched release binary and `checksums.txt`. - - Verifies SHA-256 checksum against signed asset. + - Verifies SHA-256 checksum against checksums.txt asset (fail-closed, matching install.sh). - Atomically replaces current executable via `tempfile` and `rename`. - Flags: `--dry-run`, `--force`. - Background check displays update banner in root TUI menu. diff --git a/src/cli.rs b/src/cli.rs index d8dc14d..b9ec4a9 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1088,7 +1088,15 @@ fn cmd_flagset(name: &str) -> &'static [FlagSpec] { match name { "analyze" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_JSON], "status" => &[F_DEBUG, F_HELP, F_INTERVAL, F_JSON, F_WATCH], - "clean" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_YES, F_INCLUDE, F_EXTERNAL, F_WHITELIST], + "clean" => &[ + F_DEBUG, + F_HELP, + F_DRY_RUN, + F_YES, + F_INCLUDE, + F_EXTERNAL, + F_WHITELIST, + ], "optimize" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_YES, F_SKIP, F_WHITELIST], "audit" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_JSON, F_REPORT, F_INCLUDE], "uninstall" => &[F_DEBUG, F_HELP, F_DRY_RUN, F_PERMANENT, F_LIST, F_YES], @@ -1600,7 +1608,7 @@ fn exec_completion(args: &[String]) -> Outcome { Ok(p) => { if p.get("install") { let shell = p.positionals.first().map(|s| s.as_str()).unwrap_or("bash"); - if is_shell(shell) { + if is_shell(shell) || shell == "desktop" { match install_completion(shell) { Ok(path) => { return Outcome::Stdout(format!( @@ -1706,6 +1714,10 @@ fn install_completion(shell: &str) -> std::result::Result { + return crate::remove::install_desktop_entry(None) + .map_err(|e| format!("failed to install desktop launcher: {e}")); + } _ => return Err(format!("unsupported shell for auto-install: {shell}")), }; diff --git a/src/config.rs b/src/config.rs index 278179c..819316e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -90,20 +90,48 @@ pub fn load_whitelist_from(config_home: &Path) -> Result { Error::Msg(format!("{}", e)) })?; validate_whitelist_override(&override_cfg)?; - if !override_cfg.protected_paths.system.is_empty() { - wl.protected_paths - .system - .extend(override_cfg.protected_paths.system); + for path in override_cfg.protected_paths.system { + if !path.trim().starts_with('#') { + wl.protected_paths.system.push(path); + } } - if !override_cfg.cache_skip.dirs.is_empty() { - wl.cache_skip.dirs.extend(override_cfg.cache_skip.dirs); + for dir in override_cfg.cache_skip.dirs { + if !dir.trim().starts_with('#') { + wl.cache_skip.dirs.push(dir); + } } - if !override_cfg.optimize_skip.steps.is_empty() { - wl.optimize_skip.steps = override_cfg.optimize_skip.steps; + let steps: Vec = override_cfg + .optimize_skip + .steps + .into_iter() + .filter(|s| !s.trim().starts_with('#')) + .collect(); + if !steps.is_empty() { + wl.optimize_skip.steps = steps; } Ok(wl) } +/// Loads ONLY user whitelist overrides from `/mu/config.toml`. +/// Returns Ok(None) if the file does not exist, or Ok(Some(wl)) if present. +/// Fails closed if the file is malformed. +pub fn load_user_whitelist_from(config_home: &Path) -> Result> { + let user_cfg = config_home.join("mu").join("config.toml"); + if !paths::path_exists(&user_cfg) { + return Ok(None); + } + let text = std::fs::read_to_string(&user_cfg).map_err(Error::Io)?; + let override_cfg: Whitelist = toml::from_str(&text) + .map_err(|e| Error::Msg(format!("malformed whitelist config: {e}")))?; + validate_whitelist_override(&override_cfg)?; + Ok(Some(override_cfg)) +} + +/// Loads user whitelist overrides from `$XDG_CONFIG_HOME/mu/config.toml`. +pub fn load_user_whitelist() -> Result> { + load_user_whitelist_from(&xdg::config_home()) +} + /// Saves whitelist overrides to `$XDG_CONFIG_HOME/mu/config.toml`. pub fn save_whitelist(wl: &Whitelist) -> Result { save_whitelist_to(&xdg::config_home(), wl) @@ -111,6 +139,7 @@ pub fn save_whitelist(wl: &Whitelist) -> Result { /// Testable core: saves whitelist to `/mu/config.toml`. pub fn save_whitelist_to(config_home: &Path, wl: &Whitelist) -> Result { + validate_whitelist_override(wl)?; let mu_dir = config_home.join("mu"); if !mu_dir.exists() { std::fs::create_dir_all(&mu_dir)?; @@ -118,12 +147,24 @@ pub fn save_whitelist_to(config_home: &Path, wl: &Whitelist) -> Result let user_cfg = mu_dir.join("config.toml"); let serialized = toml::to_string_pretty(wl) .map_err(|e| Error::Msg(format!("failed to serialize config: {e}")))?; - std::fs::write(&user_cfg, serialized)?; + + // Atomic write via temporary file in the same directory + let tmp_path = mu_dir.join(format!(".config.toml.tmp-{}", std::process::id())); + { + use std::io::Write; + let mut file = std::fs::File::create(&tmp_path)?; + file.write_all(serialized.as_bytes())?; + file.sync_all()?; + } + std::fs::rename(&tmp_path, &user_cfg)?; Ok(user_cfg) } fn validate_whitelist_override(override_cfg: &Whitelist) -> Result<()> { for path in &override_cfg.protected_paths.system { + if path.trim().starts_with('#') { + continue; + } if !Path::new(path).is_absolute() { return msg(format!("protected path must be absolute: {path:?}")); } @@ -132,6 +173,9 @@ fn validate_whitelist_override(override_cfg: &Whitelist) -> Result<()> { // Go applies filepath.ToSlash here — a no-op on Unix (separator is // already '/'), so the pattern is used verbatim. let pat = pattern.trim(); + if pat.starts_with('#') { + continue; + } let p = Path::new(pat); if pat.is_empty() || p.is_absolute() || pat == ".." || pat.starts_with("../") { return msg(format!( @@ -399,4 +443,42 @@ mod tests { assert_eq!(glob_match("[abc", "x"), Err(())); // unclosed class assert_eq!(glob_match("a\\", "a"), Err(())); // trailing escape } + + #[test] + fn save_whitelist_atomic_and_no_defaults_leak() { + let root = tempdir("wl-save"); + let mut custom = Whitelist::default(); + custom + .protected_paths + .system + .push("/opt/my-app".to_string()); + + let saved = save_whitelist_to(&root, &custom).expect("save succeeds"); + assert!(saved.exists()); + + let content = fs::read_to_string(&saved).unwrap(); + assert!(content.contains("/opt/my-app")); + // Must not contain embedded default paths like /etc or /boot + assert!(!content.contains("/boot")); + assert!(!content.contains("go-build")); + + // User whitelist loads back accurately + let loaded = load_user_whitelist_from(&root) + .expect("load succeeds") + .unwrap(); + assert_eq!( + loaded.protected_paths.system, + vec!["/opt/my-app".to_string()] + ); + + fs::remove_dir_all(&root).ok(); + } + + #[test] + fn load_user_whitelist_malformed_fails_closed() { + let root = tempdir("wl-user-mal"); + write_config(&root, "invalid = [toml"); + assert!(load_user_whitelist_from(&root).is_err()); + fs::remove_dir_all(&root).ok(); + } } diff --git a/src/remove.rs b/src/remove.rs index 282b9a7..e24c782 100644 --- a/src/remove.rs +++ b/src/remove.rs @@ -102,14 +102,37 @@ pub fn discover_components() -> Vec { if paths::path_exists(&local_bin) { items.push(RemovableItem { label: "Binary (~/.local/bin/mu)", - path: local_bin, + path: local_bin.clone(), }); } + + // Running executable (if user-owned and outside system prefixes) + if let Ok(current_exe) = std::env::current_exe() { + if current_exe != local_bin + && paths::path_exists(¤t_exe) + && !paths::is_protected(¤t_exe) + && !items.iter().any(|i| i.path == current_exe) + { + items.push(RemovableItem { + label: "Binary (running)", + path: current_exe, + }); + } + } } - // 7. System completion / binary if writable and exists + // 7. System binary (/usr/local/bin/mu) + let sys_bin = Path::new("/usr/local/bin/mu"); + if paths::path_exists(sys_bin) && !items.iter().any(|i| i.path == sys_bin) { + items.push(RemovableItem { + label: "System binary (/usr/local/bin/mu)", + path: sys_bin.to_path_buf(), + }); + } + + // 8. System completion (/etc/bash_completion.d/mu) let sys_bash = Path::new("/etc/bash_completion.d/mu"); - if paths::path_exists(sys_bash) && !paths::is_protected(sys_bash) { + if paths::path_exists(sys_bash) && !items.iter().any(|i| i.path == sys_bash) { items.push(RemovableItem { label: "System bash completion", path: sys_bash.to_path_buf(), @@ -119,6 +142,34 @@ pub fn discover_components() -> Vec { items } +/// Generate standard FreeDesktop desktop launcher entry. +pub fn generate_desktop_entry(exec_path: Option<&Path>) -> String { + let exec = exec_path.and_then(|p| p.to_str()).unwrap_or("mu"); + format!( + "[Desktop Entry]\n\ + Type=Application\n\ + Name=mu\n\ + Comment=System cleaner and optimizer for Ubuntu\n\ + Exec={exec}\n\ + Icon=utilities-system-monitor\n\ + Terminal=true\n\ + Categories=System;Utility;\n\ + Keywords=cleaner;optimizer;system;cache;\n" + ) +} + +/// Installs desktop launcher to `~/.local/share/applications/mu.desktop` (or custom directory). +pub fn install_desktop_entry(custom_path: Option<&Path>) -> std::io::Result { + let desktop_dir = custom_path + .map(PathBuf::from) + .unwrap_or_else(|| xdg::data_home().join("applications")); + std::fs::create_dir_all(&desktop_dir)?; + let target = desktop_dir.join("mu.desktop"); + let content = generate_desktop_entry(None); + std::fs::write(&target, content)?; + Ok(target) +} + pub fn run(opts: &Options) -> i32 { let components = discover_components(); @@ -127,15 +178,26 @@ pub fn run(opts: &Options) -> i32 { return 0; } + let (user_components, sys_components): (Vec<_>, Vec<_>) = components + .into_iter() + .partition(|item| !paths::is_protected(&item.path)); + println!("Discovered mu components to remove:"); - for item in &components { + for item in user_components.iter().chain(sys_components.iter()) { println!(" • {:<26} {}", item.label, item.path.display()); } if opts.dry_run { println!(); println!("[dry-run] DRY RUN: no files were removed."); - println!("[dry-run] All components will be moved to trash via trash::SafeDelete."); + println!("[dry-run] All user components will be moved to trash via trash::SafeDelete."); + if !sys_components.is_empty() { + println!(); + println!("[dry-run] System components require manual removal with root privileges:"); + for item in &sys_components { + println!("[dry-run] sudo rm {}", item.path.display()); + } + } return 0; } @@ -147,15 +209,7 @@ pub fn run(opts: &Options) -> i32 { } let mut failed = 0; - for item in &components { - if paths::is_protected(&item.path) { - eprintln!( - "warning: skipping protected system path: {}", - item.path.display() - ); - continue; - } - + for item in &user_components { println!("Removing {}...", item.path.display()); if let Err(e) = trash::safe_delete(&item.path, false) { eprintln!("error removing {}: {}", item.path.display(), e); @@ -163,12 +217,20 @@ pub fn run(opts: &Options) -> i32 { } } + if !sys_components.is_empty() { + println!(); + println!("The following system files require root privileges to remove. Run:"); + for item in &sys_components { + println!(" sudo rm {}", item.path.display()); + } + } + if failed > 0 { eprintln!("Completed with {failed} failure(s)."); 1 } else { println!(); - println!("✨ mu successfully removed. All data was safely moved to trash."); + println!("✨ mu successfully removed. All user data was safely moved to trash."); 0 } } @@ -196,4 +258,22 @@ mod tests { let _ = std::fs::remove_dir_all(&tmp); } + + #[test] + fn test_desktop_entry_generation() { + let content = generate_desktop_entry(Some(Path::new("/usr/local/bin/mu"))); + assert!(content.contains("[Desktop Entry]")); + assert!(content.contains("Exec=/usr/local/bin/mu")); + assert!(content.contains("Name=mu")); + } + + #[test] + fn test_install_desktop_entry() { + let tmp = std::env::temp_dir().join(format!("mu-desktop-test-{}", std::process::id())); + let installed = install_desktop_entry(Some(&tmp)).expect("install succeeds"); + assert!(installed.exists()); + let content = std::fs::read_to_string(&installed).unwrap(); + assert!(content.contains("[Desktop Entry]")); + let _ = std::fs::remove_dir_all(&tmp); + } } diff --git a/src/sudo.rs b/src/sudo.rs index 4c6835d..3a8e3cb 100644 --- a/src/sudo.rs +++ b/src/sudo.rs @@ -39,12 +39,17 @@ pub struct Options { #[derive(Clone, Debug, PartialEq, Eq)] pub struct StatusInfo { pub enabled: bool, + pub enabled_direct: bool, + pub enabled_included: bool, pub module_installed: bool, pub pam_file_exists: bool, pub pam_path: PathBuf, } pub fn is_module_installed() -> bool { + if let Ok(val) = std::env::var("MU_FPRINTD_INSTALLED") { + return val == "1" || val == "true"; + } PAM_FPRINTD_PATHS.iter().any(|p| Path::new(p).exists()) } @@ -61,41 +66,176 @@ pub fn resolve_pam_path(custom: Option<&Path>) -> PathBuf { pub fn get_status(pam_path: &Path) -> StatusInfo { let pam_file_exists = pam_path.exists(); let module_installed = is_module_installed(); - let mut enabled = false; + let mut enabled_direct = false; + let mut enabled_included = false; if pam_file_exists && let Ok(content) = fs::read_to_string(pam_path) { for line in content.lines() { let trimmed = line.trim(); - if !trimmed.starts_with('#') && trimmed.contains("pam_fprintd.so") { - enabled = true; - break; + if trimmed.starts_with('#') { + continue; + } + if trimmed.contains("pam_fprintd.so") { + enabled_direct = true; + } else if trimmed.starts_with("@include") { + let parts: Vec<&str> = trimmed.split_whitespace().collect(); + if parts.len() >= 2 { + let inc_name = parts[1]; + let inc_path = pam_path + .parent() + .unwrap_or_else(|| Path::new("/etc/pam.d")) + .join(inc_name); + if inc_path.exists() + && let Ok(inc_content) = fs::read_to_string(&inc_path) + { + for inc_line in inc_content.lines() { + let inc_trimmed = inc_line.trim(); + if !inc_trimmed.starts_with('#') + && inc_trimmed.contains("pam_fprintd.so") + { + enabled_included = true; + break; + } + } + } + } } } } StatusInfo { - enabled, + enabled: enabled_direct || enabled_included, + enabled_direct, + enabled_included, module_installed, pam_file_exists, pam_path: pam_path.to_path_buf(), } } +fn backup_pam(pam_path: &Path, content: &str) -> Result { + let parent = pam_path.parent().unwrap_or_else(|| Path::new(".")); + let file_name = pam_path + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or("sudo"); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let backup_path = parent.join(format!(".{file_name}.mu-bak-{ts}")); + fs::write(&backup_path, content) + .map_err(|e| format!("failed to create backup {}: {}", backup_path.display(), e))?; + Ok(backup_path) +} + +fn atomic_write_pam( + pam_path: &Path, + new_content: &str, + backup_path: &Path, + original_content: &str, + expected_token: &str, + require_token: bool, +) -> Result<(), String> { + let parent = pam_path.parent().unwrap_or_else(|| Path::new(".")); + let file_name = pam_path + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or("sudo"); + let temp_path = parent.join(format!(".{file_name}.mu-tmp-{}", std::process::id())); + + if let Err(e) = fs::write(&temp_path, new_content) { + return Err(format!( + "cannot write temporary PAM file {} (permission denied? run with sudo): {}", + temp_path.display(), + e + )); + } + + if let Ok(meta) = fs::metadata(pam_path) { + let _ = fs::set_permissions(&temp_path, meta.permissions()); + } + + if let Ok(file) = fs::File::open(&temp_path) { + let _ = file.sync_all(); + } + + if let Err(e) = fs::rename(&temp_path, pam_path) { + let _ = fs::remove_file(&temp_path); + let _ = fs::write(pam_path, original_content); + return Err(format!( + "failed to replace PAM file {}: {}", + pam_path.display(), + e + )); + } + + let verified = match fs::read_to_string(pam_path) { + Ok(s) => s, + Err(_) => { + let _ = fs::copy(backup_path, pam_path); + return Err( + "verification failed: cannot read back PAM file; rolled back changes".to_string(), + ); + } + }; + + if require_token && !verified.contains(expected_token) { + let _ = fs::copy(backup_path, pam_path); + return Err( + "verification failed: missing expected token after edit; rolled back changes" + .to_string(), + ); + } + if !require_token && verified.contains(expected_token) { + let _ = fs::copy(backup_path, pam_path); + return Err( + "verification failed: token still present after removal; rolled back changes" + .to_string(), + ); + } + + Ok(()) +} + pub fn enable(pam_path: &Path, dry_run: bool) -> Result<(), String> { let status = get_status(pam_path); if !status.pam_file_exists { return Err(format!("PAM file does not exist: {}", pam_path.display())); } - if status.enabled { + if status.enabled_included { + println!( + "Fingerprint authentication for sudo is already enabled via system PAM configuration (common-auth)." + ); + return Ok(()); + } + if status.enabled_direct { println!("Fingerprint authentication for sudo is already enabled."); return Ok(()); } + if dry_run { + println!("[dry-run] Would enable fingerprint authentication for sudo"); + println!("[dry-run] Target: {}", pam_path.display()); + println!("[dry-run] Backup: .sudo.mu-bak-"); + println!("[dry-run] + {FPRINTD_PAM_LINE}"); + if !status.module_installed { + println!("[dry-run] Note: pam_fprintd.so is not currently installed on this system"); + } + oplog::log_outcome("sudo", "enable", "dry-run"); + return Ok(()); + } + + if !status.module_installed { + return Err( + "pam_fprintd.so is not installed on this system (install libpam-fprintd first)" + .to_string(), + ); + } + let content = fs::read_to_string(pam_path) .map_err(|e| format!("cannot read PAM file {}: {}", pam_path.display(), e))?; - // Determine insertion position: before `@include common-auth` if present, - // otherwise before first auth or at the end. let mut new_lines = Vec::new(); let mut inserted = false; @@ -112,47 +252,21 @@ pub fn enable(pam_path: &Path, dry_run: bool) -> Result<(), String> { } let new_content = new_lines.join("\n") + "\n"; - let backup_path = pam_path.with_extension("mu-bak"); - - if dry_run { - println!("[dry-run] Would enable fingerprint authentication for sudo"); - println!("[dry-run] Target: {}", pam_path.display()); - println!("[dry-run] Backup: {}", backup_path.display()); - println!("[dry-run] + {FPRINTD_PAM_LINE}"); - if !status.module_installed { - println!("[dry-run] Note: pam_fprintd.so is not currently installed on this system"); - } - oplog::log_outcome("sudo", "enable", "dry-run"); - return Ok(()); - } - - // Backup original - fs::write(&backup_path, &content) - .map_err(|e| format!("failed to create backup {}: {}", backup_path.display(), e))?; - - // Write new content - if let Err(e) = fs::write(pam_path, &new_content) { - return Err(format!( - "cannot write to {} (permission denied? run with sudo): {}", - pam_path.display(), - e - )); - } - - // Safety verification: file must not be empty and must be readable - match fs::read_to_string(pam_path) { - Ok(verified) if verified.contains("pam_fprintd.so") => { - println!("Fingerprint authentication enabled for sudo."); - println!("Backup created at: {}", backup_path.display()); - oplog::log_outcome("sudo", "enable", "success"); - Ok(()) - } - _ => { - // Rollback - let _ = fs::write(pam_path, content); - Err("verification failed after editing PAM file; rolled back changes".to_string()) - } - } + let backup_path = backup_pam(pam_path, &content)?; + + atomic_write_pam( + pam_path, + &new_content, + &backup_path, + &content, + "pam_fprintd.so", + true, + )?; + + println!("Fingerprint authentication enabled for sudo."); + println!("Backup created at: {}", backup_path.display()); + oplog::log_outcome("sudo", "enable", "success"); + Ok(()) } pub fn disable(pam_path: &Path, dry_run: bool) -> Result<(), String> { @@ -164,6 +278,12 @@ pub fn disable(pam_path: &Path, dry_run: bool) -> Result<(), String> { println!("Fingerprint authentication for sudo is already disabled."); return Ok(()); } + if status.enabled_included && !status.enabled_direct { + return Err( + "Fingerprint authentication is managed globally via pam-auth-update (common-auth). To disable system-wide, run: sudo pam-auth-update" + .to_string(), + ); + } let content = fs::read_to_string(pam_path) .map_err(|e| format!("cannot read PAM file {}: {}", pam_path.display(), e))?; @@ -180,12 +300,11 @@ pub fn disable(pam_path: &Path, dry_run: bool) -> Result<(), String> { } let new_content = new_lines.join("\n") + "\n"; - let backup_path = pam_path.with_extension("mu-bak"); if dry_run { println!("[dry-run] Would disable fingerprint authentication for sudo"); println!("[dry-run] Target: {}", pam_path.display()); - println!("[dry-run] Backup: {}", backup_path.display()); + println!("[dry-run] Backup: .sudo.mu-bak-"); for line in &removed_lines { println!("[dry-run] - {line}"); } @@ -193,18 +312,16 @@ pub fn disable(pam_path: &Path, dry_run: bool) -> Result<(), String> { return Ok(()); } - // Backup original - fs::write(&backup_path, &content) - .map_err(|e| format!("failed to create backup {}: {}", backup_path.display(), e))?; + let backup_path = backup_pam(pam_path, &content)?; - // Write new content - if let Err(e) = fs::write(pam_path, &new_content) { - return Err(format!( - "cannot write to {} (permission denied? run with sudo): {}", - pam_path.display(), - e - )); - } + atomic_write_pam( + pam_path, + &new_content, + &backup_path, + &content, + "pam_fprintd.so", + false, + )?; println!("Fingerprint authentication disabled for sudo."); println!("Backup created at: {}", backup_path.display()); @@ -218,8 +335,10 @@ pub fn run(opts: &Options) -> i32 { match opts.command { SudoCommand::Status => { let status = get_status(&pam_path); - let state = if status.enabled { + let state = if status.enabled_direct { "enabled" + } else if status.enabled_included { + "enabled (via common-auth)" } else { "disabled" }; @@ -286,6 +405,11 @@ session required pam_env.so readenv=1 user_readenv=0 let pam_file = tmp.join("sudo"); fs::write(&pam_file, SAMPLE_PAM).unwrap(); + // Ensure module check passes in test + unsafe { + std::env::set_var("MU_FPRINTD_INSTALLED", "1"); + } + // 1. Enable dry-run let res = enable(&pam_file, true); assert!(res.is_ok()); @@ -297,12 +421,19 @@ session required pam_env.so readenv=1 user_readenv=0 assert!(res.is_ok()); let status = get_status(&pam_file); assert!(status.enabled, "real enable must enable fingerprint auth"); - - // Backup must exist - let backup = pam_file.with_extension("mu-bak"); - assert!(backup.exists(), "backup file must exist"); - let backup_content = fs::read_to_string(&backup).unwrap(); - assert!(!backup_content.contains("pam_fprintd.so")); + assert!(status.enabled_direct); + + // Backup must exist and be hidden (starts with .) + let mut found_backup = false; + for entry in fs::read_dir(&tmp).unwrap().flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with(".sudo.mu-bak-") { + found_backup = true; + let backup_content = fs::read_to_string(entry.path()).unwrap(); + assert!(!backup_content.contains("pam_fprintd.so")); + } + } + assert!(found_backup, "hidden timestamped backup file must exist"); // Content verification: line inserted before @include common-auth let modified = fs::read_to_string(&pam_file).unwrap(); @@ -324,6 +455,97 @@ session required pam_env.so readenv=1 user_readenv=0 assert!(res.is_ok()); assert!(!get_status(&pam_file).enabled); + unsafe { + std::env::remove_var("MU_FPRINTD_INSTALLED"); + } + let _ = fs::remove_dir_all(&tmp); + } + + #[test] + fn test_status_and_enable_with_common_auth_include() { + let tmp = std::env::temp_dir().join(format!("mu-sudo-inc-{}", std::process::id())); + let _ = fs::create_dir_all(&tmp); + let pam_file = tmp.join("sudo"); + fs::write(&pam_file, SAMPLE_PAM).unwrap(); + + let common_auth = tmp.join("common-auth"); + fs::write( + &common_auth, + "auth [success=1 default=ignore] pam_fprintd.so\nauth requisite pam_deny.so\n", + ) + .unwrap(); + + let status = get_status(&pam_file); + assert!(status.enabled); + assert!(status.enabled_included); + assert!(!status.enabled_direct); + + // Enable should detect it's already enabled via common-auth and not modify sudo + let res = enable(&pam_file, false); + assert!(res.is_ok()); + let sudo_content = fs::read_to_string(&pam_file).unwrap(); + assert!( + !sudo_content.contains("pam_fprintd.so"), + "must not add duplicate pam_fprintd.so when already in common-auth" + ); + + let _ = fs::remove_dir_all(&tmp); + } + + #[test] + fn test_enable_refuses_when_module_not_installed() { + let tmp = std::env::temp_dir().join(format!("mu-sudo-nomod-{}", std::process::id())); + let _ = fs::create_dir_all(&tmp); + let pam_file = tmp.join("sudo"); + fs::write(&pam_file, SAMPLE_PAM).unwrap(); + + unsafe { + std::env::set_var("MU_FPRINTD_INSTALLED", "0"); + } + + let res = enable(&pam_file, false); + assert!(res.is_err(), "must refuse when module not installed"); + assert!(res.unwrap_err().contains("not installed")); + assert!( + !get_status(&pam_file).enabled, + "PAM file must not be modified" + ); + + unsafe { + std::env::remove_var("MU_FPRINTD_INSTALLED"); + } + let _ = fs::remove_dir_all(&tmp); + } + + #[test] + fn test_atomic_write_rollback_on_verification_failure() { + let tmp = std::env::temp_dir().join(format!("mu-sudo-rollback-{}", std::process::id())); + let _ = fs::create_dir_all(&tmp); + let pam_file = tmp.join("sudo"); + fs::write(&pam_file, SAMPLE_PAM).unwrap(); + + let backup_file = tmp.join(".sudo.mu-bak-test"); + fs::write(&backup_file, SAMPLE_PAM).unwrap(); + + // Force verification failure by requiring a token that is not in the new content + let res = atomic_write_pam( + &pam_file, + "corrupted pam content", + &backup_file, + SAMPLE_PAM, + "nonexistent_token_to_force_failure", + true, + ); + assert!(res.is_err()); + assert!( + res.unwrap_err().contains("rolled back"), + "must report rollback" + ); + + // Verify pam_file was restored to original SAMPLE_PAM + let restored = fs::read_to_string(&pam_file).unwrap(); + assert_eq!(restored, SAMPLE_PAM); + let _ = fs::remove_dir_all(&tmp); } } diff --git a/src/tui/menu.rs b/src/tui/menu.rs index 2df3d6e..1a6c931 100644 --- a/src/tui/menu.rs +++ b/src/tui/menu.rs @@ -102,6 +102,7 @@ pub struct MainMenu { impl MainMenu { /// Create a new menu with the given persisted cursor and snapshot. pub fn new(cursor: usize, snapshot: Option) -> Self { + crate::update::check_background_update_silently(); Self { cursor, snapshot, diff --git a/src/tui/whitelist.rs b/src/tui/whitelist.rs index f0f19f0..c12204d 100644 --- a/src/tui/whitelist.rs +++ b/src/tui/whitelist.rs @@ -28,13 +28,23 @@ pub struct WhitelistTui { impl WhitelistTui { pub fn new() -> Self { - let config = config::load_whitelist().unwrap_or_default(); + let (config, status_msg) = match config::load_user_whitelist() { + Ok(Some(cfg)) => (cfg, None), + Ok(None) => ( + Whitelist::default(), + Some("User whitelist (~/.config/mu/config.toml)".to_string()), + ), + Err(e) => ( + Whitelist::default(), + Some(format!("Warning: error loading user config: {e}")), + ), + }; Self { tab: WhitelistTab::ProtectedPaths, cursor: 0, config, modified: false, - status_msg: None, + status_msg, } } @@ -45,6 +55,13 @@ impl WhitelistTui { WhitelistTab::OptimizeSkip => &self.config.optimize_skip.steps, } } + pub fn current_items_for(tab: WhitelistTab, config: &mut Whitelist) -> &mut Vec { + match tab { + WhitelistTab::ProtectedPaths => &mut config.protected_paths.system, + WhitelistTab::CacheSkip => &mut config.cache_skip.dirs, + WhitelistTab::OptimizeSkip => &mut config.optimize_skip.steps, + } + } pub fn handle_key_event(&mut self, key: KeyEvent) -> bool { match key.code { @@ -71,6 +88,35 @@ impl WhitelistTui { } false } + KeyCode::Char(' ') => { + let cursor = self.cursor; + let items = Self::current_items_for(self.tab, &mut self.config); + if !items.is_empty() && cursor < items.len() { + let item = &items[cursor]; + let toggled = if item.starts_with("# ") { + item.trim_start_matches("# ").to_string() + } else { + format!("# {item}") + }; + items[cursor] = toggled; + self.modified = true; + self.status_msg = Some("Entry toggled (press 's' to save)".to_string()); + } + false + } + KeyCode::Char('d') | KeyCode::Delete => { + let cursor = self.cursor; + let items = Self::current_items_for(self.tab, &mut self.config); + if !items.is_empty() && cursor < items.len() { + items.remove(cursor); + if self.cursor >= items.len() && self.cursor > 0 { + self.cursor -= 1; + } + self.modified = true; + self.status_msg = Some("Entry deleted (press 's' to save)".to_string()); + } + false + } KeyCode::Char('s') => { match config::save_whitelist(&self.config) { Ok(path) => { @@ -147,8 +193,11 @@ impl WhitelistTui { // Footer lines.push(Line::raw("")); lines.push(Line::raw("")); + let mod_indicator = if self.modified { " [Modified]" } else { "" }; lines.push(Line::styled( - " Tab switch category • ↑/↓ navigate • s save • q quit", + format!( + " Tab category • ↑/↓ move • Space toggle • d delete • s save • q quit{mod_indicator}" + ), styles::faint(), )); @@ -203,4 +252,30 @@ mod tests { let q_key = KeyEvent::from(KeyCode::Char('q')); assert!(tui.handle_key_event(q_key)); } + + #[test] + fn test_whitelist_tui_toggle_and_delete() { + let mut tui = WhitelistTui::new(); + tui.config + .protected_paths + .system + .push("/opt/test-path".to_string()); + assert_eq!(tui.current_items().len(), 1); + assert!(!tui.modified); + + // Space toggles commented state + let space_key = KeyEvent::from(KeyCode::Char(' ')); + assert!(!tui.handle_key_event(space_key)); + assert!(tui.modified); + assert_eq!(tui.current_items()[0], "# /opt/test-path"); + + // Space toggles back + assert!(!tui.handle_key_event(space_key)); + assert_eq!(tui.current_items()[0], "/opt/test-path"); + + // 'd' deletes the item + let d_key = KeyEvent::from(KeyCode::Char('d')); + assert!(!tui.handle_key_event(d_key)); + assert!(tui.current_items().is_empty()); + } } diff --git a/src/update.rs b/src/update.rs index b7645fa..043abe3 100644 --- a/src/update.rs +++ b/src/update.rs @@ -97,13 +97,98 @@ pub fn current_version() -> &'static str { } } +pub fn parse_version_tuple(v: &str) -> Option<(u64, u64, u64, u64)> { + let clean = v.trim_start_matches('v'); + if clean == "dev" { + return None; + } + let mut parts = clean.splitn(2, '-'); + let core = parts.next()?; + let suffix = parts.next(); + let nums: Vec<&str> = core.split('.').collect(); + if nums.len() < 3 { + return None; + } + let major = nums[0].parse::().ok()?; + let minor = nums[1].parse::().ok()?; + let patch = nums[2].parse::().ok()?; + let commits_ahead = if let Some(s) = suffix { + s.split('-') + .next() + .and_then(|c| c.parse::().ok()) + .unwrap_or(0) + } else { + 0 + }; + Some((major, minor, patch, commits_ahead)) +} + pub fn is_newer_version(latest_tag: &str, current: &str) -> bool { - let clean_latest = latest_tag.trim_start_matches('v'); - let clean_current = current.trim_start_matches('v'); - if clean_current == "dev" { + let clean_curr = current.trim_start_matches('v'); + if clean_curr == "dev" { return false; } - clean_latest != clean_current + match ( + parse_version_tuple(latest_tag), + parse_version_tuple(current), + ) { + (Some(latest), Some(curr)) => { + if latest.0 != curr.0 { + return latest.0 > curr.0; + } + if latest.1 != curr.1 { + return latest.1 > curr.1; + } + if latest.2 != curr.2 { + return latest.2 > curr.2; + } + latest.3 > curr.3 + } + _ => latest_tag.trim_start_matches('v') != current.trim_start_matches('v'), + } +} + +pub fn target_arch_asset_names() -> Vec { + let arch = std::env::consts::ARCH; + vec![ + format!("mu-{arch}-unknown-linux-gnu"), + format!("mu-linux-{arch}"), + BINARY_NAME.to_string(), + ] +} + +pub fn check_background_update_silently() { + let cache_dir = crate::xdg::cache_home().join("mu"); + let cache_file = cache_dir.join("last_version_check.json"); + if let Ok(meta) = fs::metadata(&cache_file) + && let Ok(modified) = meta.modified() + && let Ok(elapsed) = modified.elapsed() + && elapsed.as_secs() < 86400 + { + return; + } + let _ = std::thread::Builder::new() + .name("mu-update-check".to_string()) + .spawn(move || { + let runner = ProcessRunner; + let url = + std::env::var("MU_UPDATE_URL").unwrap_or_else(|_| DEFAULT_API_URL.to_string()); + if let Ok(rel) = fetch_release_info(&url, &runner) { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let payload = serde_json::json!({ + "latest": rel.tag_name, + "checked_at": ts, + }); + let _ = fs::create_dir_all(&cache_dir); + let tmp_file = cache_dir.join(format!(".check.tmp-{}", std::process::id())); + if fs::write(&tmp_file, payload.to_string()).is_ok() { + let _ = fs::rename(&tmp_file, &cache_file); + } + } + }); } pub fn run_with(opts: &Options, runner: &dyn Runner) -> i32 { @@ -133,12 +218,7 @@ pub fn run_with(opts: &Options, runner: &dyn Runner) -> i32 { let release = match fetch_release_info(api_url, runner) { Ok(rel) => rel, Err(e) => { - if opts.dry_run { - println!("Notice: could not query GitHub release API ({e})."); - println!("Local version is up-to-date: {ver_display}"); - return 0; - } - eprintln!("error: {e}"); + eprintln!("error: could not query GitHub release API: {e}"); return 1; } }; @@ -153,7 +233,11 @@ pub fn run_with(opts: &Options, runner: &dyn Runner) -> i32 { println!("Found newer release: {latest_tag}"); - let bin_asset = release.assets.iter().find(|a| a.name == BINARY_NAME); + let candidate_names = target_arch_asset_names(); + let bin_asset = candidate_names + .iter() + .find_map(|name| release.assets.iter().find(|a| &a.name == name)); + let resolved_bin_name = bin_asset.map(|a| a.name.as_str()).unwrap_or(BINARY_NAME); let checksum_asset = release.assets.iter().find(|a| a.name == "checksums.txt"); if opts.dry_run { @@ -195,10 +279,12 @@ pub fn run_with(opts: &Options, runner: &dyn Runner) -> i32 { }; let checksum_text = String::from_utf8_lossy(&checksum_bytes); - let expected_hash = match parse_checksum(&checksum_text, BINARY_NAME) { + let expected_hash = match parse_checksum(&checksum_text, resolved_bin_name) + .or_else(|| parse_checksum(&checksum_text, BINARY_NAME)) + { Some(h) => h, None => { - eprintln!("error: fail-closed: checksums.txt missing entry for {BINARY_NAME}"); + eprintln!("error: fail-closed: checksums.txt missing entry for {resolved_bin_name}"); return 1; } }; @@ -209,13 +295,14 @@ pub fn run_with(opts: &Options, runner: &dyn Runner) -> i32 { Some(a) => a.browser_download_url.as_str(), None => { fallback_bin_url = format!( - "https://github.com/{DEFAULT_REPO}/releases/download/{latest_tag}/{BINARY_NAME}" + "https://github.com/{DEFAULT_REPO}/releases/download/{latest_tag}/{resolved_bin_name}" ); &fallback_bin_url } }; - println!("Downloading {BINARY_NAME} {latest_tag}..."); + println!("Downloading {resolved_bin_name} {latest_tag}..."); + let new_bin_bytes = match fetch_bytes(bin_url, runner) { Ok(b) => b, Err(e) => { @@ -227,7 +314,7 @@ pub fn run_with(opts: &Options, runner: &dyn Runner) -> i32 { // Verify SHA-256 let actual_hash = compute_sha256(&new_bin_bytes); if actual_hash != expected_hash { - eprintln!("error: SHA-256 checksum mismatch for {BINARY_NAME}!"); + eprintln!("error: SHA-256 checksum mismatch for {resolved_bin_name}!"); eprintln!(" expected: {expected_hash}"); eprintln!(" actual: {actual_hash}"); eprintln!("Update aborted (fail-closed). Current binary was NOT modified."); @@ -253,9 +340,16 @@ pub fn run_with(opts: &Options, runner: &dyn Runner) -> i32 { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - let _ = fs::set_permissions(&temp_target, fs::Permissions::from_mode(0o755)); + if let Err(e) = fs::set_permissions(&temp_target, fs::Permissions::from_mode(0o755)) { + let _ = fs::remove_file(&temp_target); + eprintln!("error: failed to set executable permissions: {e}"); + return 1; + } } + if let Ok(file) = fs::File::open(&temp_target) { + let _ = file.sync_all(); + } if let Err(e) = fs::rename(&temp_target, &target_bin) { let _ = fs::remove_file(&temp_target); eprintln!("error: failed to replace {}: {}", target_bin.display(), e); @@ -384,4 +478,30 @@ e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 *other let _ = fs::remove_dir_all(&tmp); } + + #[test] + fn test_semver_ordering() { + assert!(is_newer_version("v0.4.0", "0.3.0")); + assert!(is_newer_version("0.3.1", "0.3.0")); + assert!(!is_newer_version("v0.2.0", "0.3.0")); + assert!(!is_newer_version("v0.3.0", "0.3.0")); + // Git checkout ahead of tag must NOT be downgraded to tag + assert!(!is_newer_version("v0.3.0", "0.3.0-9-gccc0649")); + // Higher tag is newer than git checkout of older base + assert!(is_newer_version("v0.4.0", "0.3.0-9-gccc0649")); + // Dev is never updated + assert!(!is_newer_version("v1.0.0", "dev")); + } + + #[test] + fn test_dry_run_api_failure_fails_closed() { + let dry_opts = Options { + dry_run: true, + force: false, + api_url: Some("file:///nonexistent/path/to/release.json".to_string()), + ..Default::default() + }; + let code = run_with(&dry_opts, &ProcessRunner); + assert_eq!(code, 1, "update dry-run must fail when release API fails"); + } } diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index be1e2c6..a4124ca 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -772,13 +772,32 @@ fn sudo_dry_run_enable() { /// `mu update --dry-run` -> reports local version and status, exit 0. #[test] fn update_dry_run() { - let o = mu(&["update", "--dry-run"]); + let tmp = std::env::temp_dir().join(format!("mu-update-dry-cli-{}", std::process::id())); + let _ = std::fs::create_dir_all(&tmp); + let release_json = tmp.join("release.json"); + let json_content = serde_json::json!({ + "tag_name": "v0.3.0", + "name": "v0.3.0", + "assets": [] + }); + std::fs::write(&release_json, json_content.to_string()).unwrap(); + + let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_mu")); + cmd.args(["update", "--dry-run"]); + cmd.env( + "MU_UPDATE_URL", + format!("file://{}", release_json.display()), + ); + let o = cmd.output().expect("run mu update --dry-run"); + assert_eq!(o.status.code(), Some(0)); - let out = stdout(&o); + let out = String::from_utf8_lossy(&o.stdout); assert!( out.contains("Checking for updates") || out.contains("up-to-date"), "unexpected output: {out}" ); + + let _ = std::fs::remove_dir_all(&tmp); } /// `mu remove --dry-run` sentinel immutability test -> exit 0, sentinel untouched. @@ -840,3 +859,30 @@ fn completion_install_bash() { let _ = std::fs::remove_dir_all(&test_dir); } +/// `mu completion --install desktop` -> writes desktop entry, exit 0. +#[test] +fn completion_install_desktop() { + let test_dir = std::env::temp_dir().join(format!("mu-comp-desktop-{}", std::process::id())); + let _ = std::fs::create_dir_all(&test_dir); + + let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_mu")); + cmd.args(["completion", "--install", "desktop"]); + cmd.env("HOME", &test_dir); + cmd.env("XDG_DATA_HOME", test_dir.join(".local/share")); + let output = cmd + .output() + .expect("failed to run mu completion --install desktop"); + + assert_eq!(output.status.code(), Some(0)); + let expected_file = test_dir.join(".local/share/applications/mu.desktop"); + assert!( + expected_file.exists(), + "installed desktop file must exist at {}", + expected_file.display() + ); + let content = std::fs::read_to_string(&expected_file).unwrap(); + assert!(content.contains("[Desktop Entry]")); + assert!(content.contains("Name=mu")); + + let _ = std::fs::remove_dir_all(&test_dir); +} From 122313e451b258f17af91223efee7985343fca1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ba=20R=E1=BB=8Di?= Date: Mon, 19 Oct 2026 11:01:30 +0700 Subject: [PATCH 16/17] fix(p6): resolve findings 1-8 and 9 (clippy, tests, xdg data, malformed remove, arch update, pam unique backup/rollback) --- docs/plans/active/mole-parity.md | 2 +- src/remove.rs | 157 +++++++++++++++++--- src/sudo.rs | 246 +++++++++++++++++++++---------- src/trash.rs | 43 ++++++ src/tui/whitelist.rs | 70 ++++++++- src/update.rs | 95 +++++++++--- 6 files changed, 487 insertions(+), 126 deletions(-) diff --git a/docs/plans/active/mole-parity.md b/docs/plans/active/mole-parity.md index 9fe3bba..e659a81 100644 --- a/docs/plans/active/mole-parity.md +++ b/docs/plans/active/mole-parity.md @@ -430,7 +430,7 @@ graph TD - Interactive Ratatui screen to view and toggle entries in `~/.config/mu/config.toml`. - **`mu update` (Self-Update):** - Queries GitHub Releases API for `https://api.github.com/repos/.../releases/latest`. - - Downloads architecture-matched release binary and `checksums.txt`. + - Downloads architecture-matched release binary (generic mu asset accepted only on x86_64) and checksums.txt. - Verifies SHA-256 checksum against checksums.txt asset (fail-closed, matching install.sh). - Atomically replaces current executable via `tempfile` and `rename`. - Flags: `--dry-run`, `--force`. diff --git a/src/remove.rs b/src/remove.rs index e24c782..bc19121 100644 --- a/src/remove.rs +++ b/src/remove.rs @@ -53,23 +53,41 @@ pub fn discover_components() -> Vec { }); } + let data_home = xdg::data_home(); let home = xdg::home_dir(); + + // 4. Desktop entry + let desktop = data_home.join("applications/mu.desktop"); + if paths::path_exists(&desktop) { + items.push(RemovableItem { + label: "Desktop launcher", + path: desktop.clone(), + }); + } if !home.as_os_str().is_empty() { - // 4. Desktop entry - let desktop = home.join(".local/share/applications/mu.desktop"); - if paths::path_exists(&desktop) { + let legacy_desktop = home.join(".local/share/applications/mu.desktop"); + if legacy_desktop != desktop && paths::path_exists(&legacy_desktop) { items.push(RemovableItem { label: "Desktop launcher", - path: desktop, + path: legacy_desktop, }); } + } - // 5. Completions - let bash_comp = home.join(".local/share/bash-completion/completions/mu"); - if paths::path_exists(&bash_comp) { + // 5. Completions + let bash_comp = data_home.join("bash-completion/completions/mu"); + if paths::path_exists(&bash_comp) { + items.push(RemovableItem { + label: "Bash completion", + path: bash_comp.clone(), + }); + } + if !home.as_os_str().is_empty() { + let legacy_bash = home.join(".local/share/bash-completion/completions/mu"); + if legacy_bash != bash_comp && paths::path_exists(&legacy_bash) { items.push(RemovableItem { label: "Bash completion", - path: bash_comp, + path: legacy_bash, }); } @@ -80,12 +98,21 @@ pub fn discover_components() -> Vec { path: zsh_comp, }); } + } - let zsh_site = home.join(".local/share/zsh/site-functions/_mu"); - if paths::path_exists(&zsh_site) { + let zsh_site = data_home.join("zsh/site-functions/_mu"); + if paths::path_exists(&zsh_site) { + items.push(RemovableItem { + label: "Zsh site completion", + path: zsh_site.clone(), + }); + } + if !home.as_os_str().is_empty() { + let legacy_zsh_site = home.join(".local/share/zsh/site-functions/_mu"); + if legacy_zsh_site != zsh_site && paths::path_exists(&legacy_zsh_site) { items.push(RemovableItem { label: "Zsh site completion", - path: zsh_site, + path: legacy_zsh_site, }); } @@ -107,17 +134,18 @@ pub fn discover_components() -> Vec { } // Running executable (if user-owned and outside system prefixes) - if let Ok(current_exe) = std::env::current_exe() { - if current_exe != local_bin - && paths::path_exists(¤t_exe) - && !paths::is_protected(¤t_exe) - && !items.iter().any(|i| i.path == current_exe) - { - items.push(RemovableItem { - label: "Binary (running)", - path: current_exe, - }); - } + // Collapsed if (Finding 1) + if let Ok(current_exe) = std::env::current_exe() + && current_exe.file_name().and_then(|n| n.to_str()) == Some("mu") + && current_exe != local_bin + && paths::path_exists(¤t_exe) + && !paths::is_protected(¤t_exe) + && !items.iter().any(|i| i.path == current_exe) + { + items.push(RemovableItem { + label: "Binary (running)", + path: current_exe, + }); } } @@ -211,7 +239,7 @@ pub fn run(opts: &Options) -> i32 { let mut failed = 0; for item in &user_components { println!("Removing {}...", item.path.display()); - if let Err(e) = trash::safe_delete(&item.path, false) { + if let Err(e) = trash::safe_delete_component(&item.path, false) { eprintln!("error removing {}: {}", item.path.display(), e); failed += 1; } @@ -276,4 +304,87 @@ mod tests { assert!(content.contains("[Desktop Entry]")); let _ = std::fs::remove_dir_all(&tmp); } + + #[test] + fn test_discover_components_finds_all_items() { + let tmp = std::env::temp_dir().join(format!("mu-discover-test-{}", std::process::id())); + let config_dir = tmp.join(".config/mu"); + let data_dir = tmp.join(".local/share/applications"); + let _ = std::fs::create_dir_all(&config_dir); + let _ = std::fs::create_dir_all(&data_dir); + let config_file = config_dir.join("config.toml"); + let desktop_file = data_dir.join("mu.desktop"); + std::fs::write(&config_file, b"# config").unwrap(); + std::fs::write(&desktop_file, b"[Desktop Entry]").unwrap(); + + let orig_home = std::env::var_os("HOME"); + let orig_config = std::env::var_os("XDG_CONFIG_HOME"); + let orig_data = std::env::var_os("XDG_DATA_HOME"); + + unsafe { + std::env::set_var("HOME", &tmp); + std::env::set_var("XDG_CONFIG_HOME", tmp.join(".config")); + std::env::set_var("XDG_DATA_HOME", tmp.join(".local/share")); + } + + let items = discover_components(); + assert!(items.iter().any(|i| i.path == config_dir)); + assert!(items.iter().any(|i| i.path == desktop_file)); + + unsafe { + match orig_home { + Some(h) => std::env::set_var("HOME", h), + None => std::env::remove_var("HOME"), + } + match orig_config { + Some(c) => std::env::set_var("XDG_CONFIG_HOME", c), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + match orig_data { + Some(d) => std::env::set_var("XDG_DATA_HOME", d), + None => std::env::remove_var("XDG_DATA_HOME"), + } + } + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn test_remove_with_malformed_config() { + let tmp = std::env::temp_dir().join(format!("mu-remove-mal-{}", std::process::id())); + let config_dir = tmp.join(".config/mu"); + let _ = std::fs::create_dir_all(&config_dir); + let bad_config = config_dir.join("config.toml"); + std::fs::write(&bad_config, b"broken = [toml").unwrap(); + + let orig_home = std::env::var_os("HOME"); + let orig_config = std::env::var_os("XDG_CONFIG_HOME"); + unsafe { + std::env::set_var("HOME", &tmp); + std::env::set_var("XDG_CONFIG_HOME", tmp.join(".config")); + } + + let opts = Options { + dry_run: false, + auto_yes: true, + debug: false, + }; + + let code = run(&opts); + assert_eq!(code, 0, "remove must succeed even if config is malformed"); + assert!(!bad_config.exists(), "malformed config must be removed"); + + unsafe { + match orig_home { + Some(h) => std::env::set_var("HOME", h), + None => std::env::remove_var("HOME"), + } + match orig_config { + Some(c) => std::env::set_var("XDG_CONFIG_HOME", c), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + } + + let _ = std::fs::remove_dir_all(&tmp); + } } diff --git a/src/sudo.rs b/src/sudo.rs index 3a8e3cb..5b766bc 100644 --- a/src/sudo.rs +++ b/src/sudo.rs @@ -47,8 +47,11 @@ pub struct StatusInfo { } pub fn is_module_installed() -> bool { - if let Ok(val) = std::env::var("MU_FPRINTD_INSTALLED") { - return val == "1" || val == "true"; + #[cfg(test)] + { + if let Ok(val) = std::env::var("MU_FPRINTD_INSTALLED") { + return val == "1" || val == "true"; + } } PAM_FPRINTD_PATHS.iter().any(|p| Path::new(p).exists()) } @@ -63,9 +66,9 @@ pub fn resolve_pam_path(custom: Option<&Path>) -> PathBuf { PathBuf::from(DEFAULT_PAM_SUDO) } -pub fn get_status(pam_path: &Path) -> StatusInfo { +pub fn get_status_with(pam_path: &Path, module_installed: Option) -> StatusInfo { let pam_file_exists = pam_path.exists(); - let module_installed = is_module_installed(); + let module_installed = module_installed.unwrap_or_else(is_module_installed); let mut enabled_direct = false; let mut enabled_included = false; @@ -113,26 +116,71 @@ pub fn get_status(pam_path: &Path) -> StatusInfo { } } +pub fn get_status(pam_path: &Path) -> StatusInfo { + get_status_with(pam_path, None) +} + fn backup_pam(pam_path: &Path, content: &str) -> Result { let parent = pam_path.parent().unwrap_or_else(|| Path::new(".")); let file_name = pam_path .file_name() .and_then(|f| f.to_str()) .unwrap_or("sudo"); - let ts = std::time::SystemTime::now() + let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - let backup_path = parent.join(format!(".{file_name}.mu-bak-{ts}")); + .unwrap_or_default(); + let nanos = now.as_nanos(); + let mut backup_path = parent.join(format!(".{file_name}.mu-bak-{nanos}")); + let mut counter = 1; + while backup_path.exists() { + backup_path = parent.join(format!(".{file_name}.mu-bak-{nanos}-{counter}")); + counter += 1; + } fs::write(&backup_path, content) .map_err(|e| format!("failed to create backup {}: {}", backup_path.display(), e))?; + if let Ok(meta) = fs::metadata(pam_path) { + let _ = fs::set_permissions(&backup_path, meta.permissions()); + } Ok(backup_path) } +fn rollback_pam( + pam_path: &Path, + original_content: &str, + original_perms: Option, +) -> Result<(), String> { + let parent = pam_path.parent().unwrap_or_else(|| Path::new(".")); + let file_name = pam_path + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or("sudo"); + let temp_path = parent.join(format!(".{file_name}.mu-rollback-{}", std::process::id())); + + fs::write(&temp_path, original_content) + .map_err(|e| format!("rollback failed to write temp file: {e}"))?; + + if let Some(perms) = &original_perms { + let _ = fs::set_permissions(&temp_path, perms.clone()); + } + + if let Ok(file) = fs::File::open(&temp_path) { + let _ = file.sync_all(); + } + + if let Err(e) = fs::rename(&temp_path, pam_path) { + let _ = fs::remove_file(&temp_path); + return Err(format!( + "rollback failed to rename over {}: {}", + pam_path.display(), + e + )); + } + Ok(()) +} + fn atomic_write_pam( pam_path: &Path, new_content: &str, - backup_path: &Path, original_content: &str, expected_token: &str, require_token: bool, @@ -144,6 +192,8 @@ fn atomic_write_pam( .unwrap_or("sudo"); let temp_path = parent.join(format!(".{file_name}.mu-tmp-{}", std::process::id())); + let original_perms = fs::metadata(pam_path).ok().map(|m| m.permissions()); + if let Err(e) = fs::write(&temp_path, new_content) { return Err(format!( "cannot write temporary PAM file {} (permission denied? run with sudo): {}", @@ -152,8 +202,8 @@ fn atomic_write_pam( )); } - if let Ok(meta) = fs::metadata(pam_path) { - let _ = fs::set_permissions(&temp_path, meta.permissions()); + if let Some(perms) = &original_perms { + let _ = fs::set_permissions(&temp_path, perms.clone()); } if let Ok(file) = fs::File::open(&temp_path) { @@ -162,44 +212,55 @@ fn atomic_write_pam( if let Err(e) = fs::rename(&temp_path, pam_path) { let _ = fs::remove_file(&temp_path); - let _ = fs::write(pam_path, original_content); - return Err(format!( - "failed to replace PAM file {}: {}", - pam_path.display(), - e - )); + let rb_res = rollback_pam(pam_path, original_content, original_perms); + return Err(match rb_res { + Ok(_) => format!("failed to replace PAM file {}: {}", pam_path.display(), e), + Err(rb_err) => format!( + "failed to replace PAM file {}: {}; rollback failed: {}", + pam_path.display(), + e, + rb_err + ), + }); } let verified = match fs::read_to_string(pam_path) { Ok(s) => s, - Err(_) => { - let _ = fs::copy(backup_path, pam_path); - return Err( - "verification failed: cannot read back PAM file; rolled back changes".to_string(), - ); + Err(read_err) => { + let rb_res = rollback_pam(pam_path, original_content, original_perms); + return Err(match rb_res { + Ok(_) => format!("verification failed ({read_err}); rolled back changes"), + Err(e) => format!("verification failed ({read_err}); rollback failed: {e}"), + }); } }; if require_token && !verified.contains(expected_token) { - let _ = fs::copy(backup_path, pam_path); - return Err( - "verification failed: missing expected token after edit; rolled back changes" + let rb_res = rollback_pam(pam_path, original_content, original_perms); + return Err(match rb_res { + Ok(_) => "verification failed: missing expected token after edit; rolled back changes" .to_string(), - ); + Err(e) => format!("verification failed: missing expected token; rollback failed: {e}"), + }); } if !require_token && verified.contains(expected_token) { - let _ = fs::copy(backup_path, pam_path); - return Err( - "verification failed: token still present after removal; rolled back changes" + let rb_res = rollback_pam(pam_path, original_content, original_perms); + return Err(match rb_res { + Ok(_) => "verification failed: token still present after removal; rolled back changes" .to_string(), - ); + Err(e) => format!("verification failed: token still present; rollback failed: {e}"), + }); } Ok(()) } -pub fn enable(pam_path: &Path, dry_run: bool) -> Result<(), String> { - let status = get_status(pam_path); +pub fn enable_with( + pam_path: &Path, + dry_run: bool, + module_installed: Option, +) -> Result<(), String> { + let status = get_status_with(pam_path, module_installed); if !status.pam_file_exists { return Err(format!("PAM file does not exist: {}", pam_path.display())); } @@ -254,14 +315,7 @@ pub fn enable(pam_path: &Path, dry_run: bool) -> Result<(), String> { let new_content = new_lines.join("\n") + "\n"; let backup_path = backup_pam(pam_path, &content)?; - atomic_write_pam( - pam_path, - &new_content, - &backup_path, - &content, - "pam_fprintd.so", - true, - )?; + atomic_write_pam(pam_path, &new_content, &content, "pam_fprintd.so", true)?; println!("Fingerprint authentication enabled for sudo."); println!("Backup created at: {}", backup_path.display()); @@ -269,6 +323,10 @@ pub fn enable(pam_path: &Path, dry_run: bool) -> Result<(), String> { Ok(()) } +pub fn enable(pam_path: &Path, dry_run: bool) -> Result<(), String> { + enable_with(pam_path, dry_run, None) +} + pub fn disable(pam_path: &Path, dry_run: bool) -> Result<(), String> { let status = get_status(pam_path); if !status.pam_file_exists { @@ -314,15 +372,7 @@ pub fn disable(pam_path: &Path, dry_run: bool) -> Result<(), String> { let backup_path = backup_pam(pam_path, &content)?; - atomic_write_pam( - pam_path, - &new_content, - &backup_path, - &content, - "pam_fprintd.so", - false, - )?; - + atomic_write_pam(pam_path, &new_content, &content, "pam_fprintd.so", false)?; println!("Fingerprint authentication disabled for sudo."); println!("Backup created at: {}", backup_path.display()); oplog::log_outcome("sudo", "disable", "success"); @@ -405,24 +455,18 @@ session required pam_env.so readenv=1 user_readenv=0 let pam_file = tmp.join("sudo"); fs::write(&pam_file, SAMPLE_PAM).unwrap(); - // Ensure module check passes in test - unsafe { - std::env::set_var("MU_FPRINTD_INSTALLED", "1"); - } - // 1. Enable dry-run - let res = enable(&pam_file, true); + let res = enable_with(&pam_file, true, Some(true)); assert!(res.is_ok()); - let status = get_status(&pam_file); + let status = get_status_with(&pam_file, Some(true)); assert!(!status.enabled, "dry-run must not modify PAM file"); // 2. Enable real - let res = enable(&pam_file, false); + let res = enable_with(&pam_file, false, Some(true)); assert!(res.is_ok()); - let status = get_status(&pam_file); + let status = get_status_with(&pam_file, Some(true)); assert!(status.enabled, "real enable must enable fingerprint auth"); assert!(status.enabled_direct); - // Backup must exist and be hidden (starts with .) let mut found_backup = false; for entry in fs::read_dir(&tmp).unwrap().flatten() { @@ -455,9 +499,6 @@ session required pam_env.so readenv=1 user_readenv=0 assert!(res.is_ok()); assert!(!get_status(&pam_file).enabled); - unsafe { - std::env::remove_var("MU_FPRINTD_INSTALLED"); - } let _ = fs::remove_dir_all(&tmp); } @@ -499,21 +540,82 @@ session required pam_env.so readenv=1 user_readenv=0 let pam_file = tmp.join("sudo"); fs::write(&pam_file, SAMPLE_PAM).unwrap(); - unsafe { - std::env::set_var("MU_FPRINTD_INSTALLED", "0"); - } - - let res = enable(&pam_file, false); + let res = enable_with(&pam_file, false, Some(false)); assert!(res.is_err(), "must refuse when module not installed"); assert!(res.unwrap_err().contains("not installed")); assert!( - !get_status(&pam_file).enabled, + !get_status_with(&pam_file, Some(false)).enabled, "PAM file must not be modified" ); - unsafe { - std::env::remove_var("MU_FPRINTD_INSTALLED"); + let _ = fs::remove_dir_all(&tmp); + } + + #[test] + fn test_disable_refuses_when_enabled_via_include() { + let tmp = std::env::temp_dir().join(format!("mu-sudo-dis-inc-{}", std::process::id())); + let _ = fs::create_dir_all(&tmp); + let pam_file = tmp.join("sudo"); + fs::write(&pam_file, SAMPLE_PAM).unwrap(); + + let common_auth = tmp.join("common-auth"); + fs::write( + &common_auth, + "auth [success=1 default=ignore] pam_fprintd.so\nauth requisite pam_deny.so\n", + ) + .unwrap(); + + let res = disable(&pam_file, false); + assert!( + res.is_err(), + "disable must refuse when enabled via common-auth" + ); + let err = res.unwrap_err(); + assert!(err.contains("managed globally via pam-auth-update")); + + let _ = fs::remove_dir_all(&tmp); + } + + #[test] + fn test_backups_unique_within_same_second_and_preserves_mode() { + let tmp = std::env::temp_dir().join(format!("mu-sudo-bak-unique-{}", std::process::id())); + let _ = fs::create_dir_all(&tmp); + let pam_file = tmp.join("sudo"); + fs::write(&pam_file, SAMPLE_PAM).unwrap(); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&pam_file, fs::Permissions::from_mode(0o440)).unwrap(); } + + let res1 = enable_with(&pam_file, false, Some(true)); + assert!(res1.is_ok()); + let res2 = disable(&pam_file, false); + assert!(res2.is_ok()); + + let mut backups = Vec::new(); + for entry in fs::read_dir(&tmp).unwrap().flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with(".sudo.mu-bak-") { + backups.push(entry.path()); + } + } + assert_eq!( + backups.len(), + 2, + "enable and disable must create two distinct backups" + ); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for b in backups { + let meta = fs::metadata(b).unwrap(); + assert_eq!(meta.permissions().mode() & 0o777, 0o440); + } + } + let _ = fs::remove_dir_all(&tmp); } @@ -524,14 +626,10 @@ session required pam_env.so readenv=1 user_readenv=0 let pam_file = tmp.join("sudo"); fs::write(&pam_file, SAMPLE_PAM).unwrap(); - let backup_file = tmp.join(".sudo.mu-bak-test"); - fs::write(&backup_file, SAMPLE_PAM).unwrap(); - // Force verification failure by requiring a token that is not in the new content let res = atomic_write_pam( &pam_file, "corrupted pam content", - &backup_file, SAMPLE_PAM, "nonexistent_token_to_force_failure", true, diff --git a/src/trash.rs b/src/trash.rs index b221237..ac5d5a1 100644 --- a/src/trash.rs +++ b/src/trash.rs @@ -128,6 +128,49 @@ pub fn safe_delete_with( } } +/// Trashes a verified mu component using default system whitelist protection, +/// allowing self-removal to succeed even when user configuration is malformed. +pub fn safe_delete_component(path: &Path, dry_run: bool) -> Result<()> { + safe_delete_component_with(&ProcessRunner, &TrashDeps::real(), path, dry_run) +} + +/// Injectable core of [`safe_delete_component`]. +pub fn safe_delete_component_with( + runner: &dyn Runner, + deps: &TrashDeps, + path: &Path, + dry_run: bool, +) -> Result<()> { + let wl = config::default_whitelist(); + if whitelist::is_whitelisted(path, &wl) { + return msg(format!("refused: {} is a protected path", path.display())); + } + std::fs::symlink_metadata(path) + .map_err(|e| Error::Msg(format!("inspect {}: {e}", path.display())))?; + if dry_run { + oplog::log_outcome("trash", &path.to_string_lossy(), "dry-run"); + return Ok(()); + } + + if runner.look_path("gio").is_ok() { + let spec = CommandSpec::new("gio", [std::ffi::OsStr::new("trash"), path.as_os_str()]); + if runner.run(&spec).is_ok() { + oplog::log_outcome("trash", &path.to_string_lossy(), "success"); + return Ok(()); + } + } + match move_to_trash(deps, path) { + Ok(_) => { + oplog::log_outcome("trash", &path.to_string_lossy(), "success"); + Ok(()) + } + Err(e) => { + oplog::log_outcome("trash", &path.to_string_lossy(), "failure"); + Err(e.with_context(format!("trash {}", path.display()))) + } + } +} + struct TrashLocation { files_dir: PathBuf, info_dir: PathBuf, diff --git a/src/tui/whitelist.rs b/src/tui/whitelist.rs index c12204d..568f4a6 100644 --- a/src/tui/whitelist.rs +++ b/src/tui/whitelist.rs @@ -24,19 +24,31 @@ pub struct WhitelistTui { pub config: Whitelist, pub modified: bool, pub status_msg: Option, + pub load_failed: bool, + pub config_home: Option, } impl WhitelistTui { pub fn new() -> Self { - let (config, status_msg) = match config::load_user_whitelist() { - Ok(Some(cfg)) => (cfg, None), + Self::new_from(None) + } + + pub fn new_from(config_home: Option<&std::path::Path>) -> Self { + let res = match config_home { + Some(p) => config::load_user_whitelist_from(p), + None => config::load_user_whitelist(), + }; + let (config, status_msg, load_failed) = match res { + Ok(Some(cfg)) => (cfg, None, false), Ok(None) => ( Whitelist::default(), Some("User whitelist (~/.config/mu/config.toml)".to_string()), + false, ), Err(e) => ( Whitelist::default(), - Some(format!("Warning: error loading user config: {e}")), + Some(format!("Error loading user config: {e}. Saving disabled.")), + true, ), }; Self { @@ -45,9 +57,10 @@ impl WhitelistTui { config, modified: false, status_msg, + load_failed, + config_home: config_home.map(|p| p.to_path_buf()), } } - pub fn current_items(&self) -> &[String] { match self.tab { WhitelistTab::ProtectedPaths => &self.config.protected_paths.system, @@ -118,7 +131,18 @@ impl WhitelistTui { false } KeyCode::Char('s') => { - match config::save_whitelist(&self.config) { + if self.load_failed { + self.status_msg = Some( + "Refusing to save: user config file was malformed on startup. Fix syntax manually to avoid data loss." + .to_string(), + ); + return false; + } + let res = match &self.config_home { + Some(p) => config::save_whitelist_to(p, &self.config), + None => config::save_whitelist(&self.config), + }; + match res { Ok(path) => { self.status_msg = Some(format!("Saved to {}", path.display())); self.modified = false; @@ -232,7 +256,8 @@ mod tests { #[test] fn test_whitelist_tui_tab_switch() { - let mut tui = WhitelistTui::new(); + let tmp = std::env::temp_dir().join(format!("mu-tui-tab-{}", std::process::id())); + let mut tui = WhitelistTui::new_from(Some(&tmp)); assert_eq!(tui.tab, WhitelistTab::ProtectedPaths); let tab_key = KeyEvent::from(KeyCode::Tab); @@ -248,14 +273,16 @@ mod tests { #[test] fn test_whitelist_tui_quit() { - let mut tui = WhitelistTui::new(); + let tmp = std::env::temp_dir().join(format!("mu-tui-quit-{}", std::process::id())); + let mut tui = WhitelistTui::new_from(Some(&tmp)); let q_key = KeyEvent::from(KeyCode::Char('q')); assert!(tui.handle_key_event(q_key)); } #[test] fn test_whitelist_tui_toggle_and_delete() { - let mut tui = WhitelistTui::new(); + let tmp = std::env::temp_dir().join(format!("mu-tui-toggle-{}", std::process::id())); + let mut tui = WhitelistTui::new_from(Some(&tmp)); tui.config .protected_paths .system @@ -278,4 +305,31 @@ mod tests { assert!(!tui.handle_key_event(d_key)); assert!(tui.current_items().is_empty()); } + + #[test] + fn test_whitelist_tui_refuses_save_on_load_error() { + let tmp = std::env::temp_dir().join(format!("mu-tui-err-{}", std::process::id())); + let mu_dir = tmp.join("mu"); + let _ = std::fs::create_dir_all(&mu_dir); + let bad_file = mu_dir.join("config.toml"); + std::fs::write(&bad_file, b"broken = [toml").unwrap(); + + let mut tui = WhitelistTui::new_from(Some(&tmp)); + assert!(tui.load_failed); + + // Pressing 's' must refuse to save + let s_key = KeyEvent::from(KeyCode::Char('s')); + assert!(!tui.handle_key_event(s_key)); + assert!( + tui.status_msg + .as_deref() + .unwrap() + .contains("Refusing to save") + ); + + // File content was NOT overwritten + assert_eq!(std::fs::read(&bad_file).unwrap(), b"broken = [toml"); + + let _ = std::fs::remove_dir_all(&tmp); + } } diff --git a/src/update.rs b/src/update.rs index 043abe3..5ad1410 100644 --- a/src/update.rs +++ b/src/update.rs @@ -149,12 +149,18 @@ pub fn is_newer_version(latest_tag: &str, current: &str) -> bool { } pub fn target_arch_asset_names() -> Vec { - let arch = std::env::consts::ARCH; - vec![ + target_arch_asset_names_for(std::env::consts::ARCH) +} + +pub fn target_arch_asset_names_for(arch: &str) -> Vec { + let mut names = vec![ format!("mu-{arch}-unknown-linux-gnu"), format!("mu-linux-{arch}"), - BINARY_NAME.to_string(), - ] + ]; + if arch == "x86_64" { + names.push(BINARY_NAME.to_string()); + } + names } pub fn check_background_update_silently() { @@ -237,17 +243,21 @@ pub fn run_with(opts: &Options, runner: &dyn Runner) -> i32 { let bin_asset = candidate_names .iter() .find_map(|name| release.assets.iter().find(|a| &a.name == name)); - let resolved_bin_name = bin_asset.map(|a| a.name.as_str()).unwrap_or(BINARY_NAME); + let bin_asset = match bin_asset { + Some(a) => a, + None => { + let arch = std::env::consts::ARCH; + eprintln!("error: no release binary for {arch}"); + return 1; + } + }; + let resolved_bin_name = &bin_asset.name; let checksum_asset = release.assets.iter().find(|a| a.name == "checksums.txt"); if opts.dry_run { println!("[dry-run] Would update mu to {latest_tag}"); println!("[dry-run] Target executable: {}", target_bin.display()); - if let Some(b) = bin_asset { - println!("[dry-run] Binary asset: {}", b.browser_download_url); - } else { - println!("[dry-run] Binary asset: default release asset '{BINARY_NAME}'"); - } + println!("[dry-run] Binary asset: {}", bin_asset.browser_download_url); if let Some(c) = checksum_asset { println!("[dry-run] Checksum asset: {}", c.browser_download_url); } else { @@ -290,16 +300,7 @@ pub fn run_with(opts: &Options, runner: &dyn Runner) -> i32 { }; // Download binary - let fallback_bin_url; - let bin_url = match bin_asset { - Some(a) => a.browser_download_url.as_str(), - None => { - fallback_bin_url = format!( - "https://github.com/{DEFAULT_REPO}/releases/download/{latest_tag}/{resolved_bin_name}" - ); - &fallback_bin_url - } - }; + let bin_url = bin_asset.browser_download_url.as_str(); println!("Downloading {resolved_bin_name} {latest_tag}..."); @@ -504,4 +505,58 @@ e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 *other let code = run_with(&dry_opts, &ProcessRunner); assert_eq!(code, 1, "update dry-run must fail when release API fails"); } + + #[test] + fn test_arch_asset_preference() { + let x86_names = target_arch_asset_names_for("x86_64"); + assert!(x86_names.contains(&"mu".to_string())); + assert!(x86_names.contains(&"mu-x86_64-unknown-linux-gnu".to_string())); + + let arm_names = target_arch_asset_names_for("aarch64"); + assert!(!arm_names.contains(&"mu".to_string())); + assert!(arm_names.contains(&"mu-aarch64-unknown-linux-gnu".to_string())); + + let release = ReleaseInfo { + tag_name: "v0.4.0".to_string(), + name: None, + body: None, + assets: vec![ + ReleaseAsset { + name: "mu".to_string(), + browser_download_url: "https://example.com/mu".to_string(), + size: None, + }, + ReleaseAsset { + name: "mu-x86_64-unknown-linux-gnu".to_string(), + browser_download_url: "https://example.com/mu-x86".to_string(), + size: None, + }, + ], + }; + + let picked = x86_names + .iter() + .find_map(|name| release.assets.iter().find(|a| &a.name == name)) + .unwrap(); + assert_eq!(picked.name, "mu-x86_64-unknown-linux-gnu"); + } + + #[test] + fn test_non_x86_rejects_generic_mu_asset() { + let arm_names = target_arch_asset_names_for("aarch64"); + let release = ReleaseInfo { + tag_name: "v0.4.0".to_string(), + name: None, + body: None, + assets: vec![ReleaseAsset { + name: "mu".to_string(), + browser_download_url: "https://example.com/mu".to_string(), + size: None, + }], + }; + let picked = arm_names + .iter() + .find_map(|name| release.assets.iter().find(|a| &a.name == name)); + assert!(picked.is_none(), "aarch64 must reject generic x86 mu asset"); + } } From 987249dd4039af66aacfc4406a4fbdf101bd7adb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ba=20R=E1=BB=8Di?= Date: Mon, 19 Oct 2026 13:54:07 +0700 Subject: [PATCH 17/17] fix(optimize): resolve clippy type_complexity and collapsible_if warnings --- src/optimize.rs | 110 ++++++++++++++++++++++++------------------------ 1 file changed, 54 insertions(+), 56 deletions(-) diff --git a/src/optimize.rs b/src/optimize.rs index d04711d..f86601b 100644 --- a/src/optimize.rs +++ b/src/optimize.rs @@ -90,12 +90,13 @@ impl Deps { /// Go `func(io.Writer) error` — the step action writing captured /// stdout/stderr to the sink. type StepRun = Box Result<()>>; +type StepDryRunLines = Arc Vec + Send + Sync>; pub(crate) struct Step { pub id: &'static str, pub desc: &'static str, pub dry_run_cmd: &'static str, - pub dry_run_lines: Option Vec + Send + Sync>>, + pub dry_run_lines: Option, pub run: StepRun, } @@ -719,13 +720,13 @@ fn find_sqlite_candidates(home: &Path) -> Vec { if let Ok(entries) = std::fs::read_dir(&ff) { for entry in entries.flatten() { let p = entry.path(); - if p.is_dir() { - if let Ok(sub) = std::fs::read_dir(&p) { - for f in sub.flatten() { - let fp = f.path(); - if fp.extension().and_then(|s| s.to_str()) == Some("sqlite") { - candidates.push(fp); - } + if p.is_dir() + && let Ok(sub) = std::fs::read_dir(&p) + { + for f in sub.flatten() { + let fp = f.path(); + if fp.extension().and_then(|s| s.to_str()) == Some("sqlite") { + candidates.push(fp); } } } @@ -772,13 +773,13 @@ fn find_sqlite_candidates(home: &Path) -> Vec { if let Ok(entries) = std::fs::read_dir(&tb) { for entry in entries.flatten() { let p = entry.path(); - if p.is_dir() { - if let Ok(sub) = std::fs::read_dir(&p) { - for f in sub.flatten() { - let fp = f.path(); - if fp.extension().and_then(|s| s.to_str()) == Some("sqlite") { - candidates.push(fp); - } + if p.is_dir() + && let Ok(sub) = std::fs::read_dir(&p) + { + for f in sub.flatten() { + let fp = f.path(); + if fp.extension().and_then(|s| s.to_str()) == Some("sqlite") { + candidates.push(fp); } } } @@ -796,10 +797,10 @@ fn is_process_running(name: &str) -> bool { if !path.is_dir() { continue; } - if let Ok(comm) = std::fs::read_to_string(path.join("comm")) { - if comm.trim() == name { - return true; - } + if let Ok(comm) = std::fs::read_to_string(path.join("comm")) + && comm.trim() == name + { + return true; } } } @@ -955,24 +956,20 @@ fn clean_stale_recursive( cleaned, errors, )?; - } else if meta.is_file() { - if let Ok(mtime) = meta.modified() { - if let Ok(age) = now.duration_since(mtime) { - if age >= max_age { - if let Err(e) = crate::paths::validate_cleanup_candidate(state_home, &p) - { - errors.push(e); - continue; - } - if let Err(e) = - trash::safe_delete_with(runner, config_home, trash_deps, &p, false) - { - errors.push(e); - } else { - *cleaned += 1; - } - } - } + } else if meta.is_file() + && let Ok(mtime) = meta.modified() + && let Ok(age) = now.duration_since(mtime) + && age >= max_age + { + if let Err(e) = crate::paths::validate_cleanup_candidate(state_home, &p) { + errors.push(e); + continue; + } + if let Err(e) = trash::safe_delete_with(runner, config_home, trash_deps, &p, false) + { + errors.push(e); + } else { + *cleaned += 1; } } } @@ -998,12 +995,12 @@ fn is_live_state(state_home: &Path, p: &Path, histfile: Option<&Path>) -> bool { return true; } } - if let Ok(rel) = p.strip_prefix(state_home) { - if let Some(std::path::Component::Normal(first)) = rel.components().next() { - return LIVE_STATE_DIRS - .iter() - .any(|d| first == std::ffi::OsStr::new(d)); - } + if let Ok(rel) = p.strip_prefix(state_home) + && let Some(std::path::Component::Normal(first)) = rel.components().next() + { + return LIVE_STATE_DIRS + .iter() + .any(|d| first == std::ffi::OsStr::new(d)); } false } @@ -1071,10 +1068,11 @@ fn services_audit(config_home: &Path, out: &mut dyn Write) -> Result<()> { }; for entry in entries.flatten() { let path = entry.path(); - if let Ok(meta) = path.symlink_metadata() { - if meta.file_type().is_symlink() && std::fs::metadata(&path).is_err() { - broken.push(path.display().to_string()); - } + if let Ok(meta) = path.symlink_metadata() + && meta.file_type().is_symlink() + && std::fs::metadata(&path).is_err() + { + broken.push(path.display().to_string()); } } } @@ -1154,15 +1152,15 @@ fn disk_health_audit(runner: &dyn Runner, out: &mut dyn Write) -> Result<()> { let mut probed = false; if runner.look_path("smartctl").is_ok() { let res = runner.run(&CommandSpec::new("smartctl", ["--scan"])); - if let Ok(output) = res { - if !output.stdout.is_empty() { - let _ = writeln!( - out, - "SMART devices detected:\n{}", - output.stdout_lossy().trim() - ); - probed = true; - } + if let Ok(output) = res + && !output.stdout.is_empty() + { + let _ = writeln!( + out, + "SMART devices detected:\n{}", + output.stdout_lossy().trim() + ); + probed = true; } } if !probed && runner.look_path("journalctl").is_ok() {