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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
confirmation-gated code paths (CQ-003).

### Fixed
- **Auto-update** — "Install" now performs a true in-place update: verifies
SHA256 hash of the downloaded build, writes an updater script that waits
for the current process to exit, copies the new executable over the old
one, and restarts. Previously it only launched the new exe from a temp
folder without replacing the original. Closes #240.
- **Disk Health** — `TemperatureColorHex` returns grey (#9AA0A6) for drives
without temperature sensors instead of misleading red (QA-004).
- **Battery Health** — `HealthPercent` clamped to 0–100, `WearPercent`
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,9 @@ long-running operation, so you always know which tab is working.
- Background download of the new build with a progress bar. If the
download is blocked, a "Manual download" button opens GitHub in the
browser.
- One-click "Install" launches the new build and hands off cleanly.
- SHA256 hash verification before install — blocks corrupted downloads.
- One-click "Install" replaces the running executable in-place and
restarts automatically (no manual file copying needed).
- Full release-note history pulled live from GitHub.

## Screenshots
Expand Down
30 changes: 28 additions & 2 deletions SysManager/SysManager.Tests/AboutViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Author: laurentiu021 · https://github.com/laurentiu021/SystemManager
// License: MIT

using System.IO;
using SysManager.Services;
using SysManager.ViewModels;

Expand Down Expand Up @@ -140,13 +141,38 @@ public void OpenDownloadFolderCommand_NoPath_DoesNotThrow()
}

[Fact]
public void InstallUpdateCommand_WithoutDownload_SetsErrorStatus()
public async Task InstallUpdateCommand_WithoutDownload_SetsErrorStatus()
{
var vm = new AboutViewModel { DownloadedPath = null };
vm.InstallUpdateCommand.Execute(null);
await vm.InstallUpdateCommand.ExecuteAsync(null);
Assert.Contains("No downloaded", vm.DownloadStatus, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task InstallUpdateCommand_WithFakePath_SetsNoFileStatus()
{
var vm = new AboutViewModel { DownloadedPath = @"C:\nonexistent\fake.exe" };
await vm.InstallUpdateCommand.ExecuteAsync(null);
Assert.Contains("No downloaded", vm.DownloadStatus, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task InstallUpdateCommand_WithPathButNoRelease_SetsNoReleaseStatus()
{
// Create a temp file to simulate a downloaded exe
var tmp = Path.GetTempFileName();
try
{
var vm = new AboutViewModel { DownloadedPath = tmp };
await vm.InstallUpdateCommand.ExecuteAsync(null);
Assert.Contains("No release info", vm.DownloadStatus, StringComparison.OrdinalIgnoreCase);
}
finally
{
File.Delete(tmp);
}
}

[Fact]
public async Task LoadHistoryCommand_NeverThrows()
{
Expand Down
84 changes: 78 additions & 6 deletions SysManager/SysManager/ViewModels/AboutViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -362,30 +362,102 @@
}

[RelayCommand]
private void InstallUpdate()
private async Task InstallUpdateAsync()
{
if (string.IsNullOrWhiteSpace(DownloadedPath) || !File.Exists(DownloadedPath))
{
DownloadStatus = "No downloaded file to install.";
return;
}

if (_latest == null)
{
DownloadStatus = "No release info available.";
return;
}

// Step 1: Verify SHA256 hash before installing.
DownloadStatus = "Verifying file integrity...";
var (verified, expected, actual) = await _updates.VerifyHashAsync(_latest, DownloadedPath);
if (!verified)
{
DownloadStatus = expected != null && actual != null
? $"SHA256 mismatch — file may be corrupted. Expected: {expected[..12]}… Got: {actual[..12]}…"
: "Hash verification failed — file may be corrupted. Try downloading again.";
return;
}
Comment on lines +379 to +388

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Hash verification is vulnerable to TOCTOU between validation and replace.

Line 381 verifies the file, but Line 418 copies it later from disk via another process. A local file swap in-between bypasses the integrity check.

🔐 Suggested hardening (re-verify in updater script right before copy)
-        var (verified, expected, actual) = await _updates.VerifyHashAsync(_latest, DownloadedPath);
+        var (verified, expected, actual) = await _updates.VerifyHashAsync(_latest, DownloadedPath);
         if (!verified)
         {
             DownloadStatus = expected != null && actual != null
                 ? $"SHA256 mismatch — file may be corrupted. Expected: {expected[..12]}… Got: {actual[..12]}…"
                 : "Hash verification failed — file may be corrupted. Try downloading again.";
             return;
         }
+        var expectedHash = (expected ?? actual ?? string.Empty).ToUpperInvariant();
...
-                echo Applying update...
+                echo Verifying hash again...
+                for /f "delims=" %%H in ('powershell -NoProfile -Command "(Get-FileHash -Path ''{DownloadedPath}'' -Algorithm SHA256).Hash"') do set "SRC_HASH=%%H"
+                if /I not "%SRC_HASH%"=="{expectedHash}" (
+                    echo Update failed — hash changed after verification.
+                    pause >NUL
+                    exit /b 1
+                )
+                echo Applying update...
                 copy /Y "{DownloadedPath}" "{currentExe}" >NUL

Also applies to: 418-419

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SysManager/SysManager/ViewModels/AboutViewModel.cs` around lines 379 - 388,
The current VerifyHashAsync call (via _updates.VerifyHashAsync with _latest and
DownloadedPath) is vulnerable to a TOCTOU race because the downloaded file can
be swapped before the later replace/copy step; fix by adding a re-check and
atomic replace: have the updater component that performs the actual file
replace/copy re-run the SHA256 verification against the same expected hash (pass
expected from VerifyHashAsync or compute expected from metadata) immediately
before performing the copy/replace, and perform the replacement atomically
(e.g., replace temp file to final location or use OS atomic rename) and/or hold
an exclusive handle while copying to prevent a swap. Ensure the verifier and the
replacer share the same expected hash value (from _updates/_latest) and that the
final copy occurs only if the re-verification succeeds.


// Step 2: Determine current executable path.
var currentExe = Environment.ProcessPath;
if (string.IsNullOrWhiteSpace(currentExe) || !File.Exists(currentExe))
{
DownloadStatus = "Cannot determine current executable path.";
return;
}

// Step 3: Write an updater script that waits for this process to exit,
// copies the new exe over the old one, then launches the new version.
try
{
var pid = Environment.ProcessId;
var scriptPath = Path.Combine(
Path.GetDirectoryName(DownloadedPath)!,
"update.cmd");

Check notice

Code scanning / CodeQL

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments Note

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments.
Comment on lines +403 to +405

var script = $"""
@echo off
title SysManager Updater
echo Waiting for SysManager to close...
:wait
tasklist /FI "PID eq {pid}" 2>NUL | find /I "{pid}" >NUL
if not errorlevel 1 (
timeout /t 1 /nobreak >NUL
goto wait
)
echo Applying update...
copy /Y "{DownloadedPath}" "{currentExe}" >NUL
if errorlevel 1 (
echo Update failed — could not copy file. Press any key to exit.
pause >NUL
exit /b 1
)
Comment on lines +419 to +423

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Updater copy failures become invisible because the batch runs hidden before shutdown.

The error path at Line 420-Line 422 is unreachable to users in practice: the script runs with CreateNoWindow = true/hidden and the app exits at Line 444. A failed copy can look like “app closed and never restarted.”

🛠️ Minimal fix to avoid silent failures
             Process.Start(new ProcessStartInfo
             {
                 FileName = "cmd.exe",
                 Arguments = $"/C \"{scriptPath}\"",
-                UseShellExecute = false,
-                CreateNoWindow = true,
-                WindowStyle = ProcessWindowStyle.Hidden
+                UseShellExecute = true,
+                CreateNoWindow = false,
+                WindowStyle = ProcessWindowStyle.Normal
             });

Also applies to: 433-444

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SysManager/SysManager/ViewModels/AboutViewModel.cs` around lines 419 - 423,
The updater batch's error path is hidden because the process is started with
CreateNoWindow = true and the app exits before the user can see failures; update
the code that generates/runs the batch (look for the batch text containing the
echo "Update failed — could not copy file." and the launcher that sets
CreateNoWindow) so that on copy failure the script writes the error to a visible
log file or to stderr (e.g., append error details and %ERRORLEVEL% to a
well-known log path) and/or launches the batch without CreateNoWindow or with a
visible MsgBox/pause that will actually be shown; ensure the error branch
includes the actual error info and that the process launching the batch (the
code that sets CreateNoWindow = true) either waits or allows the window to be
visible so failures aren’t silent.

echo Starting SysManager...
start "" "{currentExe}"
del "%~f0"
""";

await File.WriteAllTextAsync(scriptPath, script);

DownloadStatus = "Installing update — SysManager will restart...";

Process.Start(new ProcessStartInfo
{
FileName = DownloadedPath,
UseShellExecute = true // lets UAC prompt if needed
FileName = "cmd.exe",
Arguments = $"/C \"{scriptPath}\"",
UseShellExecute = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden
});
// Close the current instance so the new one takes over.

// Give the script a moment to start before we exit.
await Task.Delay(500);
System.Windows.Application.Current?.Shutdown();
}
catch (IOException ex)
{
DownloadStatus = $"Update failed: {ex.Message}";
}
catch (UnauthorizedAccessException ex)
{
DownloadStatus = $"Update failed (access denied): {ex.Message}. Try running as administrator.";
}
catch (InvalidOperationException ex)
{
DownloadStatus = $"Couldn't launch installer: {ex.Message}";
DownloadStatus = $"Update failed: {ex.Message}";
}
catch (System.ComponentModel.Win32Exception ex)
{
DownloadStatus = $"Couldn't launch installer: {ex.Message}";
DownloadStatus = $"Update failed: {ex.Message}";
}
}

Expand Down
Loading