Problem
The updater's safety story covers interruption, not badness. UpdateApplier.ApplyCopy stages the new build to targetExe + ".new" then File.Move(staging, targetExe, overwrite: true) (UpdateApplier.cs:85-91). The previous executable is destroyed by that move and never preserved anywhere. The docs are precise about what this protects against and it is not this: UpdateApplier.cs:24-27 promises only that "an interrupted copy can never leave a half-written (unlaunchable) executable at the target path", and SECURITY.md:113-116 likewise says "a staged atomic file move, so an interrupted update cannot leave a half-written, unstartable binary". Both are true. Neither helps the user whose update succeeded into a build that doesn't work. The comment at UpdateApplier.cs:114-115 ("If the swap fails the original executable is left untouched ... so a failed update can never brick the install") is about swap failure only - after a successful swap there is no original left. Given this project has shipped two launch-blocking regressions (CHANGELOG.md:1753, :2012), the scenario is not hypothetical, and the app's own design philosophy is snapshot-based reversibility for every system change (SECURITY.md and the Restore Point flow, e.g. PerformanceViewModel.cs:470) - the updater is the one mutation that isn't reversible.
Proposed solution
Preserve exactly one generation, in the folder that already exists for update artifacts rather than beside the portable exe (keeping the single-portable-file identity intact). In ApplyCopy, before the move at UpdateApplier.cs:91, copy the current target into %LOCALAPPDATA%\SysManager\updates\SysManager-previous.exe (the same directory UpdateService.cs:155-157 already creates and documents). Then expose it: in AboutViewModel, add a CanRollBack observable driven by that file's existence and a RollBackCommand that reuses the existing, already-hardened applier path - UpdateApplier.BuildArguments(currentExe, pid) + Process.Start (mirroring AboutViewModel.cs:574-584) with the previous binary as the source. Render it in AboutView.xaml as a SecondaryButton inside the version card (AboutView.xaml:44-64), labelled in plain language for the persona: "Go back to the previous version (v1.56.3)". Exclude SysManager-previous.exe from the cache-pruning pattern proposed in the sibling cache finding.
Rationale
An auto-updater without a downgrade path is a single point of failure for the entire installed base that doesn't use winget: winget users can winget install --version, but an in-app updater user has no local copy and must work out on their own that they need to find an older GitHub release. For the target persona that is a dead end - she cannot navigate a releases page and pick a prior tag. One retained generation plus one button converts "my PC tool stopped opening" into a single click, and it aligns the updater with the reversibility contract every other mutating feature in the app already honours.
Evidence
UpdateApplier.cs:85-91 (staging + File.Move(staging, targetExe, overwrite: true) - no preservation of the outgoing binary); :24-27 and :114-115 (the interruption-scoped guarantees, quoted above); SECURITY.md:113-116 (same scope). Update-artifact directory already exists: UpdateService.cs:155-158, created at :193. Existing applier invocation to reuse: AboutViewModel.cs:574-584 (BuildArguments(currentExe, pid) + Process.Start(... UseShellExecute = true)). Reversibility norm elsewhere: PerformanceViewModel.cs:470 restore-point confirmation. Historic launch-blocking releases: CHANGELOG.md:1753, :2012. No rollback surface exists today - grep -rni "rollback|roll back|previous version" SysManager/SysManager --include=*.cs --include=*.xaml returns only DNS/hosts snapshot logic (DnsHostsViewModel.cs:37, :184; HostsFileService.cs:39) and a DeepCleanup string, nothing about the app's own binary.
Risk / trade-off
Adds one file (~the size of the app) under %LOCALAPPDATA%, which is exactly what the sibling pruning finding is trying to bound - the two must land together so retention is "current + one previous", not unbounded. Rolling back re-exposes whatever bug the newer version fixed, so the button needs a confirmation via DialogService.Instance.Confirm naming the version being restored. Rollback must not fight the update banner: after a rollback the startup check will immediately re-offer the newer build, so consider suppressing the banner for the version just rolled back from (otherwise the user is nagged straight back into the broken build).
Affected area
About
Effort: M | priority: value 3/5, fit 3/5
Identified during the trust, distribution and reach audit audit.
Problem
The updater's safety story covers interruption, not badness.
UpdateApplier.ApplyCopystages the new build totargetExe + ".new"thenFile.Move(staging, targetExe, overwrite: true)(UpdateApplier.cs:85-91). The previous executable is destroyed by that move and never preserved anywhere. The docs are precise about what this protects against and it is not this: UpdateApplier.cs:24-27 promises only that "an interrupted copy can never leave a half-written (unlaunchable) executable at the target path", and SECURITY.md:113-116 likewise says "a staged atomic file move, so an interrupted update cannot leave a half-written, unstartable binary". Both are true. Neither helps the user whose update succeeded into a build that doesn't work. The comment at UpdateApplier.cs:114-115 ("If the swap fails the original executable is left untouched ... so a failed update can never brick the install") is about swap failure only - after a successful swap there is no original left. Given this project has shipped two launch-blocking regressions (CHANGELOG.md:1753, :2012), the scenario is not hypothetical, and the app's own design philosophy is snapshot-based reversibility for every system change (SECURITY.md and the Restore Point flow, e.g. PerformanceViewModel.cs:470) - the updater is the one mutation that isn't reversible.Proposed solution
Preserve exactly one generation, in the folder that already exists for update artifacts rather than beside the portable exe (keeping the single-portable-file identity intact). In
ApplyCopy, before the move at UpdateApplier.cs:91, copy the current target into%LOCALAPPDATA%\SysManager\updates\SysManager-previous.exe(the same directory UpdateService.cs:155-157 already creates and documents). Then expose it: inAboutViewModel, add aCanRollBackobservable driven by that file's existence and aRollBackCommandthat reuses the existing, already-hardened applier path -UpdateApplier.BuildArguments(currentExe, pid)+Process.Start(mirroring AboutViewModel.cs:574-584) with the previous binary as the source. Render it in AboutView.xaml as aSecondaryButtoninside the version card (AboutView.xaml:44-64), labelled in plain language for the persona: "Go back to the previous version (v1.56.3)". ExcludeSysManager-previous.exefrom the cache-pruning pattern proposed in the sibling cache finding.Rationale
An auto-updater without a downgrade path is a single point of failure for the entire installed base that doesn't use winget: winget users can
winget install --version, but an in-app updater user has no local copy and must work out on their own that they need to find an older GitHub release. For the target persona that is a dead end - she cannot navigate a releases page and pick a prior tag. One retained generation plus one button converts "my PC tool stopped opening" into a single click, and it aligns the updater with the reversibility contract every other mutating feature in the app already honours.Evidence
UpdateApplier.cs:85-91 (staging +
File.Move(staging, targetExe, overwrite: true)- no preservation of the outgoing binary); :24-27 and :114-115 (the interruption-scoped guarantees, quoted above); SECURITY.md:113-116 (same scope). Update-artifact directory already exists: UpdateService.cs:155-158, created at :193. Existing applier invocation to reuse: AboutViewModel.cs:574-584 (BuildArguments(currentExe, pid)+Process.Start(... UseShellExecute = true)). Reversibility norm elsewhere: PerformanceViewModel.cs:470 restore-point confirmation. Historic launch-blocking releases: CHANGELOG.md:1753, :2012. No rollback surface exists today -grep -rni "rollback|roll back|previous version" SysManager/SysManager --include=*.cs --include=*.xamlreturns only DNS/hosts snapshot logic (DnsHostsViewModel.cs:37, :184; HostsFileService.cs:39) and a DeepCleanup string, nothing about the app's own binary.Risk / trade-off
Adds one file (~the size of the app) under %LOCALAPPDATA%, which is exactly what the sibling pruning finding is trying to bound - the two must land together so retention is "current + one previous", not unbounded. Rolling back re-exposes whatever bug the newer version fixed, so the button needs a confirmation via
DialogService.Instance.Confirmnaming the version being restored. Rollback must not fight the update banner: after a rollback the startup check will immediately re-offer the newer build, so consider suppressing the banner for the version just rolled back from (otherwise the user is nagged straight back into the broken build).Affected area
About
Effort: M | priority: value 3/5, fit 3/5
Identified during the trust, distribution and reach audit audit.