diff --git a/Cargo.lock b/Cargo.lock index cb85b17..869d97b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -915,6 +915,7 @@ dependencies = [ "core-services", "core-trash", "dirs", + "getrandom 0.2.17", "jwalk", "libc", "serde", diff --git a/docs/superpowers/plans/2026-06-26-windows-port-phase-3-health-smart.md b/docs/superpowers/plans/2026-06-26-windows-port-phase-3-health-smart.md new file mode 100644 index 0000000..eb479c0 --- /dev/null +++ b/docs/superpowers/plans/2026-06-26-windows-port-phase-3-health-smart.md @@ -0,0 +1,405 @@ +# Windows Port — Phase 3: Disk Health & SMART — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`). + +> **Plan series.** Phase **3 of 8**. Phases 0–2 merged. Spec §5.4/§5.5. Branch: `feat/win-phase-3`. + +**Goal:** The Disk-health tab works on Windows: real disk list + host uptime (via `sysinfo`), and SMART via `smartctl` read by the elevated executor — with one-click `winget install smartmontools` when it's missing. + +**Architecture:** Disk enumeration and uptime come from the already-present cross-platform `sysinfo` crate (no new deps; mirrors the macOS pattern of `read_bytes/write_bytes = 0`). SMART is read by `smartctl.exe` (smartmontools — covers NVMe on Windows via IOCTL, so `nvme-cli` is irrelevant), invoked by the same UAC-elevated headless self-relaunch built in Phase 2 — a new `--smart` mode alongside `--apply`. `smartctl` is detected (PATH + the winget install dir) and installed via `winget` (no bundling). Throughput stays `0` (PDH deferred, like macOS). + +**Tech Stack:** Rust, `sysinfo` (existing), `smartctl` (external, winget), PowerShell RunAs (Phase-2 pattern), `core_ipc::SmartInfo`. + +## Global Constraints + +- **Target:** Windows 10 (1803+)/11 x64. Do NOT regress Linux or macOS. +- **License:** keep `// SPDX-License-Identifier: GPL-3.0-or-later`. +- **cfg rule:** split touched `#[cfg(not(target_os="macos"))]` into explicit `linux`/`windows` arms. +- **No new crate dependency** (sysinfo already present; elevation reuses Phase-2 PowerShell pattern; no `windows` crate). +- **SMART device tokens** come from `smartctl --scan-open` inside the elevated child (NOT from `sysinfo` mount points) — Windows smartctl uses its own `/dev/sdN`/`/dev/nvmeN` tokens. +- **Security:** the elevated `--smart` child runs ONLY `smartctl` (read-only) with device tokens it discovered itself via `--scan-open`; it never accepts a path/device from the un-elevated parent (the parent passes only the numeric PID, same as `--apply`). smartctl.exe is resolved from a fixed install path or PATH; prefer the fixed `C:\Program Files\smartmontools\bin\smartctl.exe`. +- **Verification:** per task = `cargo fmt --all --check` + `cargo clippy -p freeyourdisk --all-targets -- -D warnings` GREEN. Windows-only arms are CI-compile-gated; SMART runtime is manual-smoke (no smartctl/real disk on CI, same as macOS). + +--- + +### Task 1: Windows disk enumeration + uptime via sysinfo + +**Files:** Modify `src-tauri/src/health.rs` (`host_uptime_secs`, the `platform` module). + +**Interfaces:** Produces `health::disks() -> Vec` and `health::host_uptime_secs() -> u64` working on Windows. `DiskInfo` shape unchanged. + +- [ ] **Step 1: Windows `host_uptime_secs`** + +In `health.rs`, change the `#[cfg(not(target_os = "macos"))]` on `host_uptime_secs` (the `/proc/uptime` version) to `#[cfg(target_os = "linux")]`. Add a Windows arm (identical to the macOS one): + +```rust +#[cfg(target_os = "windows")] +pub fn host_uptime_secs() -> u64 { + sysinfo::System::uptime() +} +``` + +- [ ] **Step 2: Windows `platform::disks()`** + +Change the `#[cfg(not(target_os = "macos"))]` on the Linux `mod platform` to `#[cfg(target_os = "linux")]`. Add a Windows `platform` module after the macOS one: + +```rust +// --------------------------------------------------------------------------- +// Windows: sysinfo (model/rotational not exposed; throughput deferred = 0). +// --------------------------------------------------------------------------- +#[cfg(target_os = "windows")] +mod platform { + use super::DiskInfo; + use sysinfo::Disks; + + pub fn disks() -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for disk in Disks::new_with_refreshed_list().iter() { + // sysinfo lists volumes; dedupe by device name, keep physical-ish. + let name = disk.name().to_string_lossy().into_owned(); + let device = if name.is_empty() { + disk.mount_point().to_string_lossy().into_owned() + } else { + name + }; + if !seen.insert(device.clone()) { + continue; + } + out.push(DiskInfo { + device, + model: None, + size_bytes: disk.total_space(), + rotational: false, + read_bytes: 0, + write_bytes: 0, + }); + } + out + } +} +``` + +- [ ] **Step 3: Verify** — `cargo fmt --all && cargo clippy -p freeyourdisk --all-targets -- -D warnings` GREEN. (Windows `platform` is cfg-gated; read carefully. If clippy errors on `../ui/dist`, build the frontend once.) + +- [ ] **Step 4: Commit** + +```bash +git add src-tauri/src/health.rs +git commit -m "feat(win): disk enumeration + uptime via sysinfo (health tab)" +``` + +--- + +### Task 2: smartdeps Windows detection + winget install + +**Files:** Modify `src-tauri/src/smartdeps.rs` (`has_binary`, `detect_manager`, `status`), `src-tauri/src/commands.rs` (`install_smart_deps`), `src-tauri/src/execute.rs` (a Windows install path, no pkexec). + +**Interfaces:** Produces `smartdeps::status()` reporting `smartctl_installed` + `manager = Some("winget")` on Windows; `install_smart_deps` runs `winget install smartmontools.smartmontools`. + +- [ ] **Step 1: `has_binary` Windows search paths** + +In `smartdeps.rs::has_binary`, the extra-dirs list is currently POSIX. Add Windows locations under a cfg, and check `bin.exe` too. Replace the `for extra in [...]` block with a cfg-aware one: + +```rust + #[cfg(not(target_os = "windows"))] + let extra = [ + "/usr/bin", "/bin", "/usr/sbin", "/sbin", "/usr/local/bin", + "/usr/local/sbin", "/opt/homebrew/bin", "/opt/homebrew/sbin", + ]; + #[cfg(target_os = "windows")] + let extra = [ + "C:\\Program Files\\smartmontools\\bin", + "C:\\Program Files (x86)\\smartmontools\\bin", + ]; + for dir in extra { + let pb = PathBuf::from(dir); + if !dirs.contains(&pb) { + dirs.push(pb); + } + } + // On Windows, executables carry a .exe suffix. + #[cfg(target_os = "windows")] + let found = dirs.iter().any(|d| d.join(format!("{bin}.exe")).is_file()); + #[cfg(not(target_os = "windows"))] + let found = dirs.iter().any(|d| d.join(bin).is_file()); + found +} +``` + +(Replace the existing trailing `dirs.iter().any(...)` accordingly; keep the PATH collection above unchanged.) + +- [ ] **Step 2: `detect_manager` Windows arm** + +Change the `#[cfg(not(target_os = "macos"))]` on `detect_manager` to `#[cfg(target_os = "linux")]`. Add: + +```rust +/// Windows uses winget (App Installer, present on Win10 1809+/Win11). +#[cfg(target_os = "windows")] +pub fn detect_manager() -> Option { + if has_binary("winget") { + Some("winget".to_string()) + } else { + None + } +} +``` + +- [ ] **Step 3: `status()` Windows needs-detection** + +In `status()`, the `(nvme_needed, sata_needed)` cfg currently has macOS + `not(macos)`. Change `#[cfg(not(target_os = "macos"))]` to `#[cfg(target_os = "linux")]` and add a Windows arm (smartctl covers NVMe on Windows, so no separate nvme tool; flag smartmontools if any disk is present): + +```rust + #[cfg(target_os = "windows")] + let (nvme_needed, sata_needed) = (false, !disks.is_empty()); +``` + +(Leave the `nvme_installed`/`smartctl_installed`/`missing`/`can_install` logic as-is — on Windows `nvme_needed=false` so only `smartmontools` can be missing.) + +- [ ] **Step 4: `install_smart_deps` Windows arm (commands.rs)** + +In `commands.rs::install_smart_deps`, the body has `#[cfg(target_os = "macos")]` (brew) + `#[cfg(not(target_os = "macos"))]` (pkexec). Change the `not(macos)` to `#[cfg(target_os = "linux")]` and add: + +```rust + #[cfg(target_os = "windows")] + { + execute::winget_install_smart() + } +``` + +- [ ] **Step 5: `execute::winget_install_smart` (execute.rs)** + +Add to `execute.rs` (Windows-only): + +```rust +/// Windows: install smartmontools via winget. `--id` is a fixed allowlisted +/// package; nothing user-controlled reaches the command line. +#[cfg(target_os = "windows")] +pub fn winget_install_smart() -> InstallReport { + let out = Command::new("winget") + .args([ + "install", "--id", "smartmontools.smartmontools", + "--accept-source-agreements", "--accept-package-agreements", + "--silent", + ]) + .output(); + match out { + Ok(o) if o.status.success() => InstallReport { + success: true, + message: "Installed: smartmontools".to_string(), + }, + Ok(o) => InstallReport { + success: false, + message: String::from_utf8_lossy(&o.stderr) + .lines() + .last() + .unwrap_or("winget install failed") + .to_string(), + }, + Err(err) => InstallReport { + success: false, + message: format!("failed to run winget: {err}"), + }, + } +} +``` + +The `use core_ipc::InstallReport;` import is currently `#[cfg(not(target_os = "macos"))]` — on Windows it is already in scope (not(macos) includes windows). Leave it. + +- [ ] **Step 6: Verify** — `cargo fmt --all && cargo clippy -p freeyourdisk --all-targets -- -D warnings` GREEN. + +- [ ] **Step 7: Commit** + +```bash +git add src-tauri/src/smartdeps.rs src-tauri/src/commands.rs src-tauri/src/execute.rs +git commit -m "feat(win): smartctl detection + winget install of smartmontools" +``` + +--- + +### Task 3: SMART read via elevated `--smart` executor + +> ⚠️ **SECURITY — SUPERSEDED SNIPPETS BELOW.** The code shown in this task was +> hardened after implementation (adversarial review). Do NOT copy it verbatim: +> (1) the elevated child resolves `smartctl` **only** from trusted absolute +> Program Files paths — **never** the `else { "smartctl" }` PATH fallback shown +> (binary-planting EoP); (2) the elevated-IPC temp token is a **CSPRNG random +> nonce** (`elevation_token()` via `getrandom`), **not** the PID (predictable +> `%TEMP%` path → TOCTOU/report-injection). See the shipped `headless.rs` / +> `execute.rs` for the authoritative implementation. + +**Files:** Modify `src-tauri/src/headless.rs` (add `read_smart_elevated`), `src-tauri/src/main.rs` (dispatch `--smart`), `src-tauri/src/execute.rs` (Windows `pkexec_smart` real impl). + +**Interfaces:** Produces a working `execute::pkexec_smart(&[String]) -> Vec` on Windows (devices arg ignored — the elevated child self-discovers via `smartctl --scan-open`). Reuses the Phase-2 elevation IPC (numeric PID token, `%TEMP%\fyd-smart--report.json`). + +- [ ] **Step 1: `read_smart_elevated` in headless.rs** + +Add (Windows-only). It resolves smartctl, scans devices, reads each, writes a `Vec` JSON report: + +```rust +/// Windows: the elevated SMART reader. Discovers devices with +/// `smartctl --scan-open` and reads each with `smartctl -a -j`, writing a +/// Vec to %TEMP%\fyd-smart--report.json. `token` = parent PID. +#[cfg(target_os = "windows")] +pub fn read_smart_elevated(token: &str) -> i32 { + use core_ipc::SmartInfo; + if token.is_empty() || !token.bytes().all(|b| b.is_ascii_digit()) { + return 2; + } + let report_path = + std::env::temp_dir().join(format!("fyd-smart-{token}-report.json")); + + // Fixed install path first, then PATH. + let smartctl = { + let fixed = std::path::Path::new( + "C:\\Program Files\\smartmontools\\bin\\smartctl.exe", + ); + if fixed.is_file() { + fixed.to_string_lossy().into_owned() + } else { + "smartctl".to_string() + } + }; + + let scan = std::process::Command::new(&smartctl) + .args(["--scan-open", "-j"]) + .output(); + let mut results: Vec = Vec::new(); + if let Ok(out) = scan { + if let Ok(json) = serde_json::from_slice::(&out.stdout) { + if let Some(devices) = json.get("devices").and_then(|d| d.as_array()) { + for dev in devices { + let Some(name) = dev.get("name").and_then(|n| n.as_str()) else { + continue; + }; + results.push(read_one_smart(&smartctl, name)); + } + } + } + } + let json = serde_json::to_string(&results).unwrap_or_else(|_| "[]".to_string()); + if std::fs::write(&report_path, json).is_err() { + return 1; + } + 0 +} + +/// Read SMART for one device token via `smartctl -a -j`. +#[cfg(target_os = "windows")] +fn read_one_smart(smartctl: &str, device: &str) -> core_ipc::SmartInfo { + use core_ipc::SmartInfo; + let unavailable = SmartInfo { + device: device.to_string(), + available: false, + passed: None, + power_on_hours: None, + temperature_c: None, + }; + let Ok(out) = std::process::Command::new(smartctl) + .args(["-a", "-j", device]) + .output() + else { + return unavailable; + }; + let Ok(json) = serde_json::from_slice::(&out.stdout) else { + return unavailable; + }; + let passed = json + .get("smart_status") + .and_then(|s| s.get("passed")) + .and_then(|v| v.as_bool()); + let power_on_hours = json + .get("power_on_time") + .and_then(|p| p.get("hours")) + .and_then(|v| v.as_u64()); + let temperature_c = json + .get("temperature") + .and_then(|t| t.get("current")) + .and_then(|v| v.as_i64()); + let available = passed.is_some() || power_on_hours.is_some() || temperature_c.is_some(); + SmartInfo { + device: device.to_string(), + available, + passed, + power_on_hours, + temperature_c, + } +} +``` + +- [ ] **Step 2: Dispatch `--smart` in main.rs** + +After the `--apply` block in `main()`, add: + +```rust + #[cfg(target_os = "windows")] + if args.iter().any(|a| a == "--smart") { + let token = args + .iter() + .position(|a| a == "--smart") + .and_then(|i| args.get(i + 1)) + .map(String::as_str) + .unwrap_or(""); + std::process::exit(headless::read_smart_elevated(token)); + } +``` + +- [ ] **Step 3: Real Windows `pkexec_smart` in execute.rs** + +Replace the Windows `pkexec_smart` stub (returns `Vec::new()`) with the elevated reader (mirror the `pkexec_helper` PowerShell pattern, `--smart` verb, read `fyd-smart--report.json`): + +```rust +#[cfg(target_os = "windows")] +pub fn pkexec_smart(_devices: &[String]) -> Vec { + // The elevated child self-discovers devices via `smartctl --scan-open`, so + // the caller's device list is unused. One UAC prompt; report read from file. + let token = std::process::id().to_string(); + let report_path = + std::env::temp_dir().join(format!("fyd-smart-{token}-report.json")); + let _ = std::fs::remove_file(&report_path); + + let Ok(exe) = std::env::current_exe() else { + return Vec::new(); + }; + let exe_ps = exe.to_string_lossy().replace('\'', "''"); + let ps = format!( + "Start-Process -FilePath '{exe_ps}' -ArgumentList '--smart','{token}' -Verb RunAs -Wait -WindowStyle Hidden" + ); + let status = Command::new("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe") + .args(["-NoProfile", "-NonInteractive", "-Command", &ps]) + .status(); + let result = match status { + Ok(s) if s.success() => std::fs::read_to_string(&report_path) + .ok() + .and_then(|raw| serde_json::from_str(&raw).ok()) + .unwrap_or_default(), + _ => Vec::new(), + }; + let _ = std::fs::remove_file(&report_path); + result +} +``` + +- [ ] **Step 4: Verify** — `cargo fmt --all && cargo clippy -p freeyourdisk --all-targets -- -D warnings` GREEN. (All Windows-only; CI-compile-gated. SMART runtime is manual-smoke.) + +- [ ] **Step 5: Commit** + +```bash +git add src-tauri/src/headless.rs src-tauri/src/main.rs src-tauri/src/execute.rs +git commit -m "feat(win): SMART read via elevated smartctl (--smart executor)" +``` + +--- + +## Self-Review + +**Spec coverage (§5.4/§5.5):** disk enum + uptime via sysinfo (Task 1); smartctl detection + winget install, nvme-cli not needed (Task 2); SMART read via elevated smartctl (Task 3). Throughput = 0 (PDH deferred, documented, matches macOS). `is_physical_disk` not needed on Windows (sysinfo lists real volumes; deduped by device). + +**Placeholder scan:** none. The deferred PDH throughput is an explicit, documented degradation (returns 0), not a placeholder. + +**Type consistency:** `pkexec_smart(&[String]) -> Vec` signature identical across OSes (callers unchanged). `read_smart_elevated`/`read_one_smart` produce `core_ipc::SmartInfo`. The `--smart` elevation reuses the Phase-2 numeric-PID token + `%TEMP%` report-file IPC; security guard (digit-only token) replicated. + +## Notes for later +- Throughput (PDH `\PhysicalDisk(*)\Disk R/W Bytes/sec`) deferred — revisit if the live graph matters on Windows. +- SMART read is manual-smoke only (no smartctl/real disk on CI; same as macOS). +- Device model/rotational via WMI is a future enhancement (sysinfo doesn't expose them). diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 23a8263..2adb4e2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,6 +22,8 @@ sysinfo = "0.39" jwalk = { workspace = true } tauri-plugin-global-shortcut = "2" dirs = "6" +# CSPRNG for unguessable elevated-IPC temp file tokens (TOCTOU hardening). +getrandom = "0.2" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 748e84c..3db4745 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -344,7 +344,7 @@ pub async fn install_smart_deps() -> Result { smartdeps::brew_install(&packages) } // Linux: privileged package manager via the pkexec helper. - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "linux")] { let Some(manager) = smartdeps::detect_manager() else { return InstallReport { @@ -354,6 +354,11 @@ pub async fn install_smart_deps() -> Result { }; execute::pkexec_install_deps(&manager, &packages) } + // Windows: winget (App Installer, ships with Win10 1809+/Win11). + #[cfg(target_os = "windows")] + { + execute::winget_install_smart() + } }) .await .map_err(|e| e.to_string()) diff --git a/src-tauri/src/execute.rs b/src-tauri/src/execute.rs index f25db33..851d78c 100644 --- a/src-tauri/src/execute.rs +++ b/src-tauri/src/execute.rs @@ -17,11 +17,14 @@ use std::process::Command; #[cfg(target_os = "linux")] use std::process::Stdio; -/// Installed location of the privileged helper. +/// Installed location of the privileged helper (Linux/macOS only — Windows +/// elevates in-process via PowerShell and never invokes the helper binary). +#[cfg(not(target_os = "windows"))] pub const HELPER_PATH: &str = "/usr/lib/freeyourdisk/freeyourdisk-helper"; /// Resolve the helper binary: the installed path in production, or a sibling of -/// the running executable when developing (`cargo tauri dev`). +/// the running executable when developing (`cargo tauri dev`). Linux/macOS only. +#[cfg(not(target_os = "windows"))] pub fn resolve_helper_path() -> PathBuf { let installed = PathBuf::from(HELPER_PATH); if installed.exists() { @@ -154,8 +157,8 @@ pub fn pkexec_helper(plan: &DeletionPlan) -> ExecutionReport { } /// Windows: relaunch THIS exe elevated (UAC) in headless `--apply` mode to run -/// the root plan. Only the parent PID is passed as an argument (no spaces); both -/// sides derive `%TEMP%\fyd-apply--{plan,report}.json`. Elevation uses +/// the root plan. Only a random digit-only token is passed as an argument; both +/// sides derive `%TEMP%\fyd-apply--{plan,report}.json`. Elevation uses /// `powershell Start-Process -Verb RunAs -Wait` — the WinAPI-free analogue of the /// macOS osascript-admin path. #[cfg(target_os = "windows")] @@ -164,7 +167,7 @@ pub fn pkexec_helper(plan: &DeletionPlan) -> ExecutionReport { Ok(json) => json, Err(err) => return err_report(plan, &err.to_string()), }; - let token = std::process::id().to_string(); + let token = elevation_token(); let tmp = std::env::temp_dir(); let plan_path = tmp.join(format!("fyd-apply-{token}-plan.json")); let report_path = tmp.join(format!("fyd-apply-{token}-report.json")); @@ -223,11 +226,33 @@ pub fn pkexec_smart(devices: &[String]) -> Vec { } } -/// Windows SMART is implemented in Phase 3 (bundled smartctl.exe via the elevated -/// executor). Until then, return no SMART data (the UI degrades gracefully). #[cfg(target_os = "windows")] pub fn pkexec_smart(_devices: &[String]) -> Vec { - Vec::new() + // The elevated child self-discovers devices via `smartctl --scan-open`, so + // the caller's device list is unused. One UAC prompt; report read from file. + let token = elevation_token(); + let report_path = std::env::temp_dir().join(format!("fyd-smart-{token}-report.json")); + let _ = std::fs::remove_file(&report_path); + + let Ok(exe) = std::env::current_exe() else { + return Vec::new(); + }; + let exe_ps = exe.to_string_lossy().replace('\'', "''"); + let ps = format!( + "Start-Process -FilePath '{exe_ps}' -ArgumentList '--smart','{token}' -Verb RunAs -Wait -WindowStyle Hidden" + ); + let status = Command::new("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe") + .args(["-NoProfile", "-NonInteractive", "-Command", &ps]) + .status(); + let result = match status { + Ok(s) if s.success() => std::fs::read_to_string(&report_path) + .ok() + .and_then(|raw| serde_json::from_str(&raw).ok()) + .unwrap_or_default(), + _ => Vec::new(), + }; + let _ = std::fs::remove_file(&report_path); + result } // --------------------------------------------------------------------------- @@ -300,7 +325,8 @@ pub fn pkexec_smart(devices: &[String]) -> Vec { /// Install SMART tools as root via the helper (`install-deps …`). /// The helper re-validates the package names against its own allowlist. /// (Linux only — macOS installs via Homebrew at user level.) -#[cfg(not(target_os = "macos"))] +// Linux only: macOS installs via brew, Windows via winget (winget_install_smart). +#[cfg(target_os = "linux")] pub fn pkexec_install_deps(manager: &str, packages: &[String]) -> InstallReport { let mut cmd = Command::new("pkexec"); cmd.arg(resolve_helper_path()) @@ -321,6 +347,54 @@ pub fn pkexec_install_deps(manager: &str, packages: &[String]) -> InstallReport } } +/// Windows: install smartmontools via winget. `--id` is a fixed allowlisted +/// package; nothing user-controlled reaches the command line. +#[cfg(target_os = "windows")] +pub fn winget_install_smart() -> InstallReport { + let out = Command::new("winget") + .args([ + "install", + "--id", + "smartmontools.smartmontools", + "--accept-source-agreements", + "--accept-package-agreements", + "--silent", + ]) + .output(); + match out { + Ok(o) if o.status.success() => InstallReport { + success: true, + message: "Installed: smartmontools".to_string(), + }, + Ok(o) => InstallReport { + success: false, + message: String::from_utf8_lossy(&o.stderr) + .lines() + .last() + .unwrap_or("winget install failed") + .to_string(), + }, + Err(err) => InstallReport { + success: false, + message: format!("failed to run winget: {err}"), + }, + } +} + +/// A random, unguessable token (20 decimal digits) for the elevated-IPC temp +/// file names. Random — not the PID — so a local same-user attacker cannot +/// pre-create a junction/file at a predictable path (TOCTOU) to redirect the +/// admin write or inject a forged report. Digit-only to satisfy the elevated +/// child's token guard. Falls back to the PID only if the OS RNG is unavailable. +#[allow(dead_code)] // used only by the Windows elevated executors +fn elevation_token() -> String { + let mut buf = [0u8; 8]; + match getrandom::getrandom(&mut buf) { + Ok(()) => format!("{:020}", u64::from_le_bytes(buf)), + Err(_) => std::process::id().to_string(), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/headless.rs b/src-tauri/src/headless.rs index c32032f..b122a4f 100644 --- a/src-tauri/src/headless.rs +++ b/src-tauri/src/headless.rs @@ -134,11 +134,11 @@ pub fn run(args: &[String]) -> i32 { /// Windows: the elevated child. Reads the plan staged by the un-elevated parent /// at `%TEMP%\fyd-apply--plan.json`, re-validates against the hard-coded /// Windows root zone (`%WINDIR%\Temp`), deletes, and writes the report to -/// `%TEMP%\fyd-apply--report.json`. `token` is the parent PID (no spaces). +/// `%TEMP%\fyd-apply--report.json`. `token` is a random digit-only nonce. #[cfg(target_os = "windows")] pub fn apply_elevated(token: &str) -> i32 { use core_trash::Zones; - // Hardening: this runs ELEVATED. `token` (the parent PID) is interpolated + // Hardening: this runs ELEVATED. `token` (a random digit-only nonce) is interpolated // into a %TEMP% path — reject anything but ASCII digits so a crafted token // can never traverse out of %TEMP% (arbitrary admin file read/write). if token.is_empty() || !token.bytes().all(|b| b.is_ascii_digit()) { @@ -174,6 +174,104 @@ pub fn apply_elevated(token: &str) -> i32 { 0 } +/// Windows: the elevated SMART reader. Discovers devices with +/// `smartctl --scan-open` and reads each with `smartctl -a -j`, writing a +/// Vec to %TEMP%\fyd-smart--report.json. `token` = random nonce. +#[cfg(target_os = "windows")] +pub fn read_smart_elevated(token: &str) -> i32 { + use core_ipc::SmartInfo; + if token.is_empty() || !token.bytes().all(|b| b.is_ascii_digit()) { + return 2; + } + let report_path = std::env::temp_dir().join(format!("fyd-smart-{token}-report.json")); + + // Elevated context: resolve smartctl ONLY from trusted absolute install + // locations. NEVER fall back to PATH — a planted smartctl.exe on a PATH dir + // would execute as admin (binary-planting EoP). Absent → SMART unavailable. + let smartctl = match [ + "C:\\Program Files\\smartmontools\\bin\\smartctl.exe", + "C:\\Program Files (x86)\\smartmontools\\bin\\smartctl.exe", + ] + .into_iter() + .find(|p| std::path::Path::new(p).is_file()) + { + Some(path) => path.to_string(), + None => { + // smartmontools not installed → no SMART data (expected). Surface a + // write failure (exit 1) rather than masking it as success. + return if std::fs::write(&report_path, "[]").is_ok() { + 0 + } else { + 1 + }; + } + }; + + let scan = std::process::Command::new(&smartctl) + .args(["--scan-open", "-j"]) + .output(); + let mut results: Vec = Vec::new(); + if let Ok(out) = scan { + if let Ok(json) = serde_json::from_slice::(&out.stdout) { + if let Some(devices) = json.get("devices").and_then(|d| d.as_array()) { + for dev in devices { + let Some(name) = dev.get("name").and_then(|n| n.as_str()) else { + continue; + }; + results.push(read_one_smart(&smartctl, name)); + } + } + } + } + let json = serde_json::to_string(&results).unwrap_or_else(|_| "[]".to_string()); + if std::fs::write(&report_path, json).is_err() { + return 1; + } + 0 +} + +/// Read SMART for one device token via `smartctl -a -j`. +#[cfg(target_os = "windows")] +fn read_one_smart(smartctl: &str, device: &str) -> core_ipc::SmartInfo { + use core_ipc::SmartInfo; + let unavailable = SmartInfo { + device: device.to_string(), + available: false, + passed: None, + power_on_hours: None, + temperature_c: None, + }; + let Ok(out) = std::process::Command::new(smartctl) + .args(["-a", "-j", device]) + .output() + else { + return unavailable; + }; + let Ok(json) = serde_json::from_slice::(&out.stdout) else { + return unavailable; + }; + let passed = json + .get("smart_status") + .and_then(|s| s.get("passed")) + .and_then(|v| v.as_bool()); + let power_on_hours = json + .get("power_on_time") + .and_then(|p| p.get("hours")) + .and_then(|v| v.as_u64()); + let temperature_c = json + .get("temperature") + .and_then(|t| t.get("current")) + .and_then(|v| v.as_i64()); + let available = passed.is_some() || power_on_hours.is_some() || temperature_c.is_some(); + SmartInfo { + device: device.to_string(), + available, + passed, + power_on_hours, + temperature_c, + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/health.rs b/src-tauri/src/health.rs index a4d1657..cda9ed5 100644 --- a/src-tauri/src/health.rs +++ b/src-tauri/src/health.rs @@ -23,7 +23,7 @@ pub struct DiskInfo { } /// Host uptime in seconds. -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "linux")] pub fn host_uptime_secs() -> u64 { std::fs::read_to_string("/proc/uptime") .ok() @@ -33,6 +33,11 @@ pub fn host_uptime_secs() -> u64 { .unwrap_or(0) } +#[cfg(target_os = "windows")] +pub fn host_uptime_secs() -> u64 { + sysinfo::System::uptime() +} + #[cfg(target_os = "macos")] pub fn host_uptime_secs() -> u64 { sysinfo::System::uptime() @@ -41,7 +46,7 @@ pub fn host_uptime_secs() -> u64 { // --------------------------------------------------------------------------- // Linux: /proc + /sys // --------------------------------------------------------------------------- -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "linux")] mod platform { use super::DiskInfo; use std::fs; @@ -187,6 +192,41 @@ mod platform { } } +// --------------------------------------------------------------------------- +// Windows: sysinfo (model/rotational not exposed; throughput deferred = 0). +// --------------------------------------------------------------------------- +#[cfg(target_os = "windows")] +mod platform { + use super::DiskInfo; + use sysinfo::Disks; + + pub fn disks() -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for disk in Disks::new_with_refreshed_list().iter() { + // sysinfo lists volumes (not physical disks); dedupe by device name. + let name = disk.name().to_string_lossy().into_owned(); + let device = if name.is_empty() { + disk.mount_point().to_string_lossy().into_owned() + } else { + name + }; + if !seen.insert(device.clone()) { + continue; + } + out.push(DiskInfo { + device, + model: None, + size_bytes: disk.total_space(), + rotational: false, + read_bytes: 0, + write_bytes: 0, + }); + } + out + } +} + /// Snapshot of every physical disk: profile + cumulative I/O counters. pub fn disks() -> Vec { platform::disks() diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index fe795ae..1aeb0c4 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -40,6 +40,16 @@ fn main() { .unwrap_or(""); std::process::exit(headless::apply_elevated(token)); } + #[cfg(target_os = "windows")] + if args.iter().any(|a| a == "--smart") { + let token = args + .iter() + .position(|a| a == "--smart") + .and_then(|i| args.get(i + 1)) + .map(String::as_str) + .unwrap_or(""); + std::process::exit(headless::read_smart_elevated(token)); + } // Stay alive and responsive under memory pressure (best effort). taskmgr::raise_priority(); diff --git a/src-tauri/src/smartdeps.rs b/src-tauri/src/smartdeps.rs index e686723..d6630aa 100644 --- a/src-tauri/src/smartdeps.rs +++ b/src-tauri/src/smartdeps.rs @@ -32,27 +32,39 @@ fn has_binary(bin: &str) -> bool { let mut dirs: Vec = std::env::var_os("PATH") .map(|p| std::env::split_paths(&p).collect()) .unwrap_or_default(); - for extra in [ + #[cfg(not(target_os = "windows"))] + let extra = [ "/usr/bin", "/bin", "/usr/sbin", "/sbin", "/usr/local/bin", "/usr/local/sbin", - "/opt/homebrew/bin", // macOS / Apple Silicon Homebrew + "/opt/homebrew/bin", "/opt/homebrew/sbin", - ] { - let pb = PathBuf::from(extra); + ]; + #[cfg(target_os = "windows")] + let extra = [ + "C:\\Program Files\\smartmontools\\bin", + "C:\\Program Files (x86)\\smartmontools\\bin", + ]; + for dir in extra { + let pb = PathBuf::from(dir); if !dirs.contains(&pb) { dirs.push(pb); } } - dirs.iter().any(|d| d.join(bin).is_file()) + // On Windows, executables carry a .exe suffix. + #[cfg(target_os = "windows")] + let found = dirs.iter().any(|d| d.join(format!("{bin}.exe")).is_file()); + #[cfg(not(target_os = "windows"))] + let found = dirs.iter().any(|d| d.join(bin).is_file()); + found } /// Detect the system package manager by its binary (most reliable across /// derivatives — an Ubuntu flavour still has `apt-get`). -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "linux")] pub fn detect_manager() -> Option { for (bin, key) in [ ("apt-get", "apt"), @@ -67,6 +79,16 @@ pub fn detect_manager() -> Option { None } +/// Windows uses winget (App Installer, present on Win10 1809+/Win11). +#[cfg(target_os = "windows")] +pub fn detect_manager() -> Option { + if has_binary("winget") { + Some("winget".to_string()) + } else { + None + } +} + /// macOS uses Homebrew. (Apple Silicon installs it under /opt/homebrew/bin.) #[cfg(target_os = "macos")] pub fn detect_manager() -> Option { @@ -84,13 +106,16 @@ pub fn status() -> SmartDepsStatus { // `diskN`, so we just key off "any disk present → smartmontools". #[cfg(target_os = "macos")] let (nvme_needed, sata_needed) = (false, !disks.is_empty()); - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "linux")] let (nvme_needed, sata_needed) = ( disks.iter().any(|d| d.device.starts_with("nvme")), disks .iter() .any(|d| d.device.starts_with("sd") || d.device.starts_with("hd")), ); + // On Windows, smartmontools covers NVMe natively — no separate nvme-cli needed. + #[cfg(target_os = "windows")] + let (nvme_needed, sata_needed) = (false, !disks.is_empty()); let nvme_installed = has_binary("nvme"); let smartctl_installed = has_binary("smartctl");