Skip to content

Commit 9b2d476

Browse files
authored
Merge pull request #9 from QrCommunication/feat/win-phase-3
Windows port — Phase 3: disk health & SMART
2 parents c75f062 + 70c1151 commit 9b2d476

9 files changed

Lines changed: 681 additions & 21 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/superpowers/plans/2026-06-26-windows-port-phase-3-health-smart.md

Lines changed: 405 additions & 0 deletions
Large diffs are not rendered by default.

src-tauri/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ sysinfo = "0.39"
2222
jwalk = { workspace = true }
2323
tauri-plugin-global-shortcut = "2"
2424
dirs = "6"
25+
# CSPRNG for unguessable elevated-IPC temp file tokens (TOCTOU hardening).
26+
getrandom = "0.2"
2527

2628
[target.'cfg(unix)'.dependencies]
2729
libc = "0.2"

src-tauri/src/commands.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ pub async fn install_smart_deps() -> Result<InstallReport, String> {
344344
smartdeps::brew_install(&packages)
345345
}
346346
// Linux: privileged package manager via the pkexec helper.
347-
#[cfg(not(target_os = "macos"))]
347+
#[cfg(target_os = "linux")]
348348
{
349349
let Some(manager) = smartdeps::detect_manager() else {
350350
return InstallReport {
@@ -354,6 +354,11 @@ pub async fn install_smart_deps() -> Result<InstallReport, String> {
354354
};
355355
execute::pkexec_install_deps(&manager, &packages)
356356
}
357+
// Windows: winget (App Installer, ships with Win10 1809+/Win11).
358+
#[cfg(target_os = "windows")]
359+
{
360+
execute::winget_install_smart()
361+
}
357362
})
358363
.await
359364
.map_err(|e| e.to_string())

src-tauri/src/execute.rs

Lines changed: 83 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,14 @@ use std::process::Command;
1717
#[cfg(target_os = "linux")]
1818
use std::process::Stdio;
1919

20-
/// Installed location of the privileged helper.
20+
/// Installed location of the privileged helper (Linux/macOS only — Windows
21+
/// elevates in-process via PowerShell and never invokes the helper binary).
22+
#[cfg(not(target_os = "windows"))]
2123
pub const HELPER_PATH: &str = "/usr/lib/freeyourdisk/freeyourdisk-helper";
2224

2325
/// Resolve the helper binary: the installed path in production, or a sibling of
24-
/// the running executable when developing (`cargo tauri dev`).
26+
/// the running executable when developing (`cargo tauri dev`). Linux/macOS only.
27+
#[cfg(not(target_os = "windows"))]
2528
pub fn resolve_helper_path() -> PathBuf {
2629
let installed = PathBuf::from(HELPER_PATH);
2730
if installed.exists() {
@@ -154,8 +157,8 @@ pub fn pkexec_helper(plan: &DeletionPlan) -> ExecutionReport {
154157
}
155158

156159
/// Windows: relaunch THIS exe elevated (UAC) in headless `--apply` mode to run
157-
/// the root plan. Only the parent PID is passed as an argument (no spaces); both
158-
/// sides derive `%TEMP%\fyd-apply-<pid>-{plan,report}.json`. Elevation uses
160+
/// the root plan. Only a random digit-only token is passed as an argument; both
161+
/// sides derive `%TEMP%\fyd-apply-<token>-{plan,report}.json`. Elevation uses
159162
/// `powershell Start-Process -Verb RunAs -Wait` — the WinAPI-free analogue of the
160163
/// macOS osascript-admin path.
161164
#[cfg(target_os = "windows")]
@@ -164,7 +167,7 @@ pub fn pkexec_helper(plan: &DeletionPlan) -> ExecutionReport {
164167
Ok(json) => json,
165168
Err(err) => return err_report(plan, &err.to_string()),
166169
};
167-
let token = std::process::id().to_string();
170+
let token = elevation_token();
168171
let tmp = std::env::temp_dir();
169172
let plan_path = tmp.join(format!("fyd-apply-{token}-plan.json"));
170173
let report_path = tmp.join(format!("fyd-apply-{token}-report.json"));
@@ -223,11 +226,33 @@ pub fn pkexec_smart(devices: &[String]) -> Vec<SmartInfo> {
223226
}
224227
}
225228

226-
/// Windows SMART is implemented in Phase 3 (bundled smartctl.exe via the elevated
227-
/// executor). Until then, return no SMART data (the UI degrades gracefully).
228229
#[cfg(target_os = "windows")]
229230
pub fn pkexec_smart(_devices: &[String]) -> Vec<SmartInfo> {
230-
Vec::new()
231+
// The elevated child self-discovers devices via `smartctl --scan-open`, so
232+
// the caller's device list is unused. One UAC prompt; report read from file.
233+
let token = elevation_token();
234+
let report_path = std::env::temp_dir().join(format!("fyd-smart-{token}-report.json"));
235+
let _ = std::fs::remove_file(&report_path);
236+
237+
let Ok(exe) = std::env::current_exe() else {
238+
return Vec::new();
239+
};
240+
let exe_ps = exe.to_string_lossy().replace('\'', "''");
241+
let ps = format!(
242+
"Start-Process -FilePath '{exe_ps}' -ArgumentList '--smart','{token}' -Verb RunAs -Wait -WindowStyle Hidden"
243+
);
244+
let status = Command::new("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
245+
.args(["-NoProfile", "-NonInteractive", "-Command", &ps])
246+
.status();
247+
let result = match status {
248+
Ok(s) if s.success() => std::fs::read_to_string(&report_path)
249+
.ok()
250+
.and_then(|raw| serde_json::from_str(&raw).ok())
251+
.unwrap_or_default(),
252+
_ => Vec::new(),
253+
};
254+
let _ = std::fs::remove_file(&report_path);
255+
result
231256
}
232257

233258
// ---------------------------------------------------------------------------
@@ -300,7 +325,8 @@ pub fn pkexec_smart(devices: &[String]) -> Vec<SmartInfo> {
300325
/// Install SMART tools as root via the helper (`install-deps <manager> <pkg>…`).
301326
/// The helper re-validates the package names against its own allowlist.
302327
/// (Linux only — macOS installs via Homebrew at user level.)
303-
#[cfg(not(target_os = "macos"))]
328+
// Linux only: macOS installs via brew, Windows via winget (winget_install_smart).
329+
#[cfg(target_os = "linux")]
304330
pub fn pkexec_install_deps(manager: &str, packages: &[String]) -> InstallReport {
305331
let mut cmd = Command::new("pkexec");
306332
cmd.arg(resolve_helper_path())
@@ -321,6 +347,54 @@ pub fn pkexec_install_deps(manager: &str, packages: &[String]) -> InstallReport
321347
}
322348
}
323349

350+
/// Windows: install smartmontools via winget. `--id` is a fixed allowlisted
351+
/// package; nothing user-controlled reaches the command line.
352+
#[cfg(target_os = "windows")]
353+
pub fn winget_install_smart() -> InstallReport {
354+
let out = Command::new("winget")
355+
.args([
356+
"install",
357+
"--id",
358+
"smartmontools.smartmontools",
359+
"--accept-source-agreements",
360+
"--accept-package-agreements",
361+
"--silent",
362+
])
363+
.output();
364+
match out {
365+
Ok(o) if o.status.success() => InstallReport {
366+
success: true,
367+
message: "Installed: smartmontools".to_string(),
368+
},
369+
Ok(o) => InstallReport {
370+
success: false,
371+
message: String::from_utf8_lossy(&o.stderr)
372+
.lines()
373+
.last()
374+
.unwrap_or("winget install failed")
375+
.to_string(),
376+
},
377+
Err(err) => InstallReport {
378+
success: false,
379+
message: format!("failed to run winget: {err}"),
380+
},
381+
}
382+
}
383+
384+
/// A random, unguessable token (20 decimal digits) for the elevated-IPC temp
385+
/// file names. Random — not the PID — so a local same-user attacker cannot
386+
/// pre-create a junction/file at a predictable path (TOCTOU) to redirect the
387+
/// admin write or inject a forged report. Digit-only to satisfy the elevated
388+
/// child's token guard. Falls back to the PID only if the OS RNG is unavailable.
389+
#[allow(dead_code)] // used only by the Windows elevated executors
390+
fn elevation_token() -> String {
391+
let mut buf = [0u8; 8];
392+
match getrandom::getrandom(&mut buf) {
393+
Ok(()) => format!("{:020}", u64::from_le_bytes(buf)),
394+
Err(_) => std::process::id().to_string(),
395+
}
396+
}
397+
324398
#[cfg(test)]
325399
mod tests {
326400
use super::*;

src-tauri/src/headless.rs

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,11 +134,11 @@ pub fn run(args: &[String]) -> i32 {
134134
/// Windows: the elevated child. Reads the plan staged by the un-elevated parent
135135
/// at `%TEMP%\fyd-apply-<token>-plan.json`, re-validates against the hard-coded
136136
/// Windows root zone (`%WINDIR%\Temp`), deletes, and writes the report to
137-
/// `%TEMP%\fyd-apply-<token>-report.json`. `token` is the parent PID (no spaces).
137+
/// `%TEMP%\fyd-apply-<token>-report.json`. `token` is a random digit-only nonce.
138138
#[cfg(target_os = "windows")]
139139
pub fn apply_elevated(token: &str) -> i32 {
140140
use core_trash::Zones;
141-
// Hardening: this runs ELEVATED. `token` (the parent PID) is interpolated
141+
// Hardening: this runs ELEVATED. `token` (a random digit-only nonce) is interpolated
142142
// into a %TEMP% path — reject anything but ASCII digits so a crafted token
143143
// can never traverse out of %TEMP% (arbitrary admin file read/write).
144144
if token.is_empty() || !token.bytes().all(|b| b.is_ascii_digit()) {
@@ -174,6 +174,104 @@ pub fn apply_elevated(token: &str) -> i32 {
174174
0
175175
}
176176

177+
/// Windows: the elevated SMART reader. Discovers devices with
178+
/// `smartctl --scan-open` and reads each with `smartctl -a -j`, writing a
179+
/// Vec<SmartInfo> to %TEMP%\fyd-smart-<token>-report.json. `token` = random nonce.
180+
#[cfg(target_os = "windows")]
181+
pub fn read_smart_elevated(token: &str) -> i32 {
182+
use core_ipc::SmartInfo;
183+
if token.is_empty() || !token.bytes().all(|b| b.is_ascii_digit()) {
184+
return 2;
185+
}
186+
let report_path = std::env::temp_dir().join(format!("fyd-smart-{token}-report.json"));
187+
188+
// Elevated context: resolve smartctl ONLY from trusted absolute install
189+
// locations. NEVER fall back to PATH — a planted smartctl.exe on a PATH dir
190+
// would execute as admin (binary-planting EoP). Absent → SMART unavailable.
191+
let smartctl = match [
192+
"C:\\Program Files\\smartmontools\\bin\\smartctl.exe",
193+
"C:\\Program Files (x86)\\smartmontools\\bin\\smartctl.exe",
194+
]
195+
.into_iter()
196+
.find(|p| std::path::Path::new(p).is_file())
197+
{
198+
Some(path) => path.to_string(),
199+
None => {
200+
// smartmontools not installed → no SMART data (expected). Surface a
201+
// write failure (exit 1) rather than masking it as success.
202+
return if std::fs::write(&report_path, "[]").is_ok() {
203+
0
204+
} else {
205+
1
206+
};
207+
}
208+
};
209+
210+
let scan = std::process::Command::new(&smartctl)
211+
.args(["--scan-open", "-j"])
212+
.output();
213+
let mut results: Vec<SmartInfo> = Vec::new();
214+
if let Ok(out) = scan {
215+
if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&out.stdout) {
216+
if let Some(devices) = json.get("devices").and_then(|d| d.as_array()) {
217+
for dev in devices {
218+
let Some(name) = dev.get("name").and_then(|n| n.as_str()) else {
219+
continue;
220+
};
221+
results.push(read_one_smart(&smartctl, name));
222+
}
223+
}
224+
}
225+
}
226+
let json = serde_json::to_string(&results).unwrap_or_else(|_| "[]".to_string());
227+
if std::fs::write(&report_path, json).is_err() {
228+
return 1;
229+
}
230+
0
231+
}
232+
233+
/// Read SMART for one device token via `smartctl -a -j`.
234+
#[cfg(target_os = "windows")]
235+
fn read_one_smart(smartctl: &str, device: &str) -> core_ipc::SmartInfo {
236+
use core_ipc::SmartInfo;
237+
let unavailable = SmartInfo {
238+
device: device.to_string(),
239+
available: false,
240+
passed: None,
241+
power_on_hours: None,
242+
temperature_c: None,
243+
};
244+
let Ok(out) = std::process::Command::new(smartctl)
245+
.args(["-a", "-j", device])
246+
.output()
247+
else {
248+
return unavailable;
249+
};
250+
let Ok(json) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else {
251+
return unavailable;
252+
};
253+
let passed = json
254+
.get("smart_status")
255+
.and_then(|s| s.get("passed"))
256+
.and_then(|v| v.as_bool());
257+
let power_on_hours = json
258+
.get("power_on_time")
259+
.and_then(|p| p.get("hours"))
260+
.and_then(|v| v.as_u64());
261+
let temperature_c = json
262+
.get("temperature")
263+
.and_then(|t| t.get("current"))
264+
.and_then(|v| v.as_i64());
265+
let available = passed.is_some() || power_on_hours.is_some() || temperature_c.is_some();
266+
SmartInfo {
267+
device: device.to_string(),
268+
available,
269+
passed,
270+
power_on_hours,
271+
temperature_c,
272+
}
273+
}
274+
177275
#[cfg(test)]
178276
mod tests {
179277
use super::*;

src-tauri/src/health.rs

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ pub struct DiskInfo {
2323
}
2424

2525
/// Host uptime in seconds.
26-
#[cfg(not(target_os = "macos"))]
26+
#[cfg(target_os = "linux")]
2727
pub fn host_uptime_secs() -> u64 {
2828
std::fs::read_to_string("/proc/uptime")
2929
.ok()
@@ -33,6 +33,11 @@ pub fn host_uptime_secs() -> u64 {
3333
.unwrap_or(0)
3434
}
3535

36+
#[cfg(target_os = "windows")]
37+
pub fn host_uptime_secs() -> u64 {
38+
sysinfo::System::uptime()
39+
}
40+
3641
#[cfg(target_os = "macos")]
3742
pub fn host_uptime_secs() -> u64 {
3843
sysinfo::System::uptime()
@@ -41,7 +46,7 @@ pub fn host_uptime_secs() -> u64 {
4146
// ---------------------------------------------------------------------------
4247
// Linux: /proc + /sys
4348
// ---------------------------------------------------------------------------
44-
#[cfg(not(target_os = "macos"))]
49+
#[cfg(target_os = "linux")]
4550
mod platform {
4651
use super::DiskInfo;
4752
use std::fs;
@@ -187,6 +192,41 @@ mod platform {
187192
}
188193
}
189194

195+
// ---------------------------------------------------------------------------
196+
// Windows: sysinfo (model/rotational not exposed; throughput deferred = 0).
197+
// ---------------------------------------------------------------------------
198+
#[cfg(target_os = "windows")]
199+
mod platform {
200+
use super::DiskInfo;
201+
use sysinfo::Disks;
202+
203+
pub fn disks() -> Vec<DiskInfo> {
204+
let mut seen = std::collections::HashSet::new();
205+
let mut out = Vec::new();
206+
for disk in Disks::new_with_refreshed_list().iter() {
207+
// sysinfo lists volumes (not physical disks); dedupe by device name.
208+
let name = disk.name().to_string_lossy().into_owned();
209+
let device = if name.is_empty() {
210+
disk.mount_point().to_string_lossy().into_owned()
211+
} else {
212+
name
213+
};
214+
if !seen.insert(device.clone()) {
215+
continue;
216+
}
217+
out.push(DiskInfo {
218+
device,
219+
model: None,
220+
size_bytes: disk.total_space(),
221+
rotational: false,
222+
read_bytes: 0,
223+
write_bytes: 0,
224+
});
225+
}
226+
out
227+
}
228+
}
229+
190230
/// Snapshot of every physical disk: profile + cumulative I/O counters.
191231
pub fn disks() -> Vec<DiskInfo> {
192232
platform::disks()

src-tauri/src/main.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,16 @@ fn main() {
4040
.unwrap_or("");
4141
std::process::exit(headless::apply_elevated(token));
4242
}
43+
#[cfg(target_os = "windows")]
44+
if args.iter().any(|a| a == "--smart") {
45+
let token = args
46+
.iter()
47+
.position(|a| a == "--smart")
48+
.and_then(|i| args.get(i + 1))
49+
.map(String::as_str)
50+
.unwrap_or("");
51+
std::process::exit(headless::read_smart_elevated(token));
52+
}
4353

4454
// Stay alive and responsive under memory pressure (best effort).
4555
taskmgr::raise_priority();

0 commit comments

Comments
 (0)