Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

405 changes: 405 additions & 0 deletions docs/superpowers/plans/2026-06-26-windows-port-phase-3-health-smart.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 6 additions & 1 deletion src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,7 @@ pub async fn install_smart_deps() -> Result<InstallReport, String> {
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 {
Expand All @@ -354,6 +354,11 @@ pub async fn install_smart_deps() -> Result<InstallReport, String> {
};
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())
Expand Down
92 changes: 83 additions & 9 deletions src-tauri/src/execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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")]
Expand All @@ -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"));
Expand Down Expand Up @@ -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"));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use a report path shared across UAC accounts

This report path is derived from the unelevated user's %TEMP%, but read_smart_elevated derives its own path with std::env::temp_dir() after Start-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 👍 / 👎.

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
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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())
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require an exact winget package ID

Microsoft's winget install docs say --id only restricts which field is searched and --exact is needed to require an exact match. On systems with an added source or another ID containing this string, the one-click install can become ambiguous or select the wrong package despite the intended allowlist; add --exact/-e and preferably pin the source.

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(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fail closed instead of reusing predictable PID tokens

If the OS RNG returns an error, this falls back to the process ID for the temp-file token used by the Windows elevated --apply/--smart IPC. In that environment the path is predictable again, so a same-user attacker can pre-create the report path/junction or forge a report before the elevated process writes it; avoid launching elevation or return an error instead of continuing with a PID token.

Useful? React with 👍 / 👎.

}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
102 changes: 100 additions & 2 deletions src-tauri/src/headless.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down Expand Up @@ -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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return SMART keys that match disk cards

On Windows this returns each SmartInfo keyed by the raw smartctl --scan-open name, but the UI builds smart[info.device] and renders only smart[disk.device], while the Windows disk list keys cards from sysinfo volume names/mount points (health.rs:208-213). When those identifiers differ, as with normal Windows drive-letter volumes versus smartctl device tokens, a successful SMART read is never attached to any disk card and the health tab hides the SMART data.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve smartctl scan device types

When --scan-open finds a device that needs an explicit type, this drops the reported type and later calls only smartctl -a -j <name>. The smartctl man page says --scan reports device type/protocol, --scan-open may change that type through autodetection, and -d TYPE is how callers specify it, so USB/SAT/RAID/NVMe devices that scan successfully can still fail the subsequent read and show no Windows SMART data.

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::*;
Expand Down
44 changes: 42 additions & 2 deletions src-tauri/src/health.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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;
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep Windows volume identifiers unique

When two Windows volumes have the same non-empty label, this uses that label from disk.name() as the device; the seen set below then treats them as the same disk and drops later volumes, so health_overview and SMART dependency status omit real drives. Use the mount point or volume GUID as the stable key and keep the label separate for display.

Useful? React with 👍 / 👎.

};
if !seen.insert(device.clone()) {
continue;
}
out.push(DiskInfo {
device,
model: None,
size_bytes: disk.total_space(),
rotational: false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not hard-code Windows disks as SSD

On Windows machines with an HDD, this forces rotational to false, and the health UI renders false as SSD, so every Windows HDD is mislabeled. sysinfo exposes disk kind information on Windows; otherwise this needs an unknown state instead of mapping unavailable data to SSD.

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()
Expand Down
10 changes: 10 additions & 0 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading