-
Notifications
You must be signed in to change notification settings - Fork 0
Windows port — Phase 3: disk health & SMART #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
056f56c
4b361fa
701c127
092a92d
24f4bf0
ab74c30
68864d5
70c1151
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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-<pid>-{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-<token>-{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<SmartInfo> { | |
| } | ||
| } | ||
|
|
||
| /// 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<SmartInfo> { | ||
| 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<SmartInfo> { | |
| /// Install SMART tools as root via the helper (`install-deps <manager> <pkg>…`). | ||
| /// 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", | ||
| ]) | ||
|
Comment on lines
+354
to
+362
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Microsoft's Useful? React with 👍 / 👎. |
||
| .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(), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If the OS RNG returns an error, this falls back to the process ID for the temp-file token used by the Windows elevated Useful? React with 👍 / 👎. |
||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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-<token>-plan.json`, re-validates against the hard-coded | ||
| /// Windows root zone (`%WINDIR%\Temp`), deletes, and writes the report to | ||
| /// `%TEMP%\fyd-apply-<token>-report.json`. `token` is the parent PID (no spaces). | ||
| /// `%TEMP%\fyd-apply-<token>-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<SmartInfo> to %TEMP%\fyd-smart-<token>-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<SmartInfo> = Vec::new(); | ||
| if let Ok(out) = scan { | ||
| if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&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)); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On Windows this returns each Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| } | ||
| } | ||
| } | ||
| } | ||
| 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::<serde_json::Value>(&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::*; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<DiskInfo> { | ||
| 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 | ||
|
Comment on lines
+208
to
+212
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When two Windows volumes have the same non-empty label, this uses that label from Useful? React with 👍 / 👎. |
||
| }; | ||
| if !seen.insert(device.clone()) { | ||
| continue; | ||
| } | ||
| out.push(DiskInfo { | ||
| device, | ||
| model: None, | ||
| size_bytes: disk.total_space(), | ||
| rotational: false, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On Windows machines with an HDD, this forces Useful? React with 👍 / 👎. |
||
| read_bytes: 0, | ||
| write_bytes: 0, | ||
| }); | ||
| } | ||
| out | ||
| } | ||
| } | ||
|
|
||
| /// Snapshot of every physical disk: profile + cumulative I/O counters. | ||
| pub fn disks() -> Vec<DiskInfo> { | ||
| platform::disks() | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This report path is derived from the unelevated user's
%TEMP%, butread_smart_elevatedderives its own path withstd::env::temp_dir()afterStart-Process -Verb RunAs. When UAC prompts for different admin credentials, the child runs under that admin account and writes to the admin temp directory, so the parent never sees the report and returns empty SMART despite a successful read; this affects standard Windows accounts.Useful? React with 👍 / 👎.