From cd4684222f92c3c867cad8c081dc4f2ab5337998 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Thu, 10 Sep 2026 16:20:54 -0400 Subject: [PATCH 1/4] Explain WinGet installer hash mismatches instead of dead-ending --- src/Languages/lang_en.json | 5 +- .../OperationHistoryActionService.cs | 5 +- .../DialogPages/OperationViewModel.cs | 3 +- .../OperationFailedDialog.axaml.cs | 3 +- .../IpcOperationApi.cs | 8 +- .../Helpers/WinGetPkgOperationHelper.cs | 7 +- .../WinGet.cs | 6 + .../PackageOperations.cs | 71 +++++++- .../WinGetManagerTests.cs | 166 ++++++++++++++++++ 9 files changed, 263 insertions(+), 11 deletions(-) diff --git a/src/Languages/lang_en.json b/src/Languages/lang_en.json index bdb556e706..1994397e90 100644 --- a/src/Languages/lang_en.json +++ b/src/Languages/lang_en.json @@ -1072,5 +1072,8 @@ "UniGetUI is running in portable mode and started with empty settings. Settings from a previous installation were found at {0}.": "UniGetUI is running in portable mode and started with empty settings. Settings from a previous installation were found at {0}.", "Settings imported": "Settings imported", "{0} file(s) were copied. Restart UniGetUI to apply them.": "{0} file(s) were copied. Restart UniGetUI to apply them.", - "Could not import settings": "Could not import settings" + "Could not import settings": "Could not import settings", + "The installer for {package} does not match the hash published in its manifest, and WinGet refuses to skip that check while running as administrator": "The installer for {package} does not match the hash published in its manifest, and WinGet refuses to skip that check while running as administrator", + "The installer for {package} does not match the hash published in its manifest; WinGet only skips that check once its InstallerHashOverride administrator setting is enabled": "The installer for {package} does not match the hash published in its manifest; WinGet only skips that check once its InstallerHashOverride administrator setting is enabled", + "The installer for {package} does not match the hash published in its manifest, so it was not run": "The installer for {package} does not match the hash published in its manifest, so it was not run" } diff --git a/src/UniGetUI.Avalonia/Infrastructure/OperationHistoryActionService.cs b/src/UniGetUI.Avalonia/Infrastructure/OperationHistoryActionService.cs index 1c803466f2..cf958c63a6 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/OperationHistoryActionService.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/OperationHistoryActionService.cs @@ -55,8 +55,9 @@ public static (bool AsAdmin, bool Interactive, bool SkipHash) GetRetryModes(Oper var options = LoadOptions(record); bool asAdmin = manager.Capabilities.CanRunAsAdmin && !options.RunAsAdministrator; bool interactive = manager.Capabilities.CanRunInteractively && !options.InteractiveInstallation; - bool skipHash = manager.Capabilities.CanSkipIntegrityChecks && !options.SkipHashCheck - && record.Role != (int)OperationType.Uninstall; + bool skipHash = PackageOperation.CanRetrySkippingIntegrityChecks( + manager, options, (OperationType)record.Role, + CoreTools.IsAdministrator() || options.RunAsAdministrator); return (asAdmin, interactive, skipHash); } diff --git a/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs index 07fdf9c40c..a5690fd5d8 100644 --- a/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs @@ -266,7 +266,8 @@ private void RebuildMenu(OperationStatus status) OpMenu.Items.Add(Item("Retry interactively", "interactive.svg", true, () => Operation.Retry(AbstractOperation.RetryMode.Retry_Interactive))); - if (!pkgOp.Options.SkipHashCheck && caps.CanSkipIntegrityChecks) + if (PackageOperation.CanRetrySkippingIntegrityChecks( + pkgOp.Package.Manager, pkgOp.Options, pkgOp.Role, pkgOp.WillRunElevated)) OpMenu.Items.Add(Item("Retry skipping integrity checks", "checksum.svg", true, () => Operation.Retry(AbstractOperation.RetryMode.Retry_SkipIntegrity))); } diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/OperationFailedDialog.axaml.cs b/src/UniGetUI.Avalonia/Views/DialogPages/OperationFailedDialog.axaml.cs index b012b766cc..c9f55e05a3 100644 --- a/src/UniGetUI.Avalonia/Views/DialogPages/OperationFailedDialog.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/DialogPages/OperationFailedDialog.axaml.cs @@ -100,7 +100,8 @@ private Control BuildRetryButton(AbstractOperation operation) retryOptions.Add(MenuItem(CoreTools.Translate("Retry interactively"), () => { operation.Retry(AbstractOperation.RetryMode.Retry_Interactive); Close(); })); - if (!pkgOp.Options.SkipHashCheck && caps.CanSkipIntegrityChecks) + if (PackageOperation.CanRetrySkippingIntegrityChecks( + pkgOp.Package.Manager, pkgOp.Options, pkgOp.Role, pkgOp.WillRunElevated)) retryOptions.Add(MenuItem(CoreTools.Translate("Retry skipping integrity checks"), () => { operation.Retry(AbstractOperation.RetryMode.Retry_SkipIntegrity); Close(); })); } diff --git a/src/UniGetUI.Interface.IpcApi/IpcOperationApi.cs b/src/UniGetUI.Interface.IpcApi/IpcOperationApi.cs index 782d833843..a9feb35d71 100644 --- a/src/UniGetUI.Interface.IpcApi/IpcOperationApi.cs +++ b/src/UniGetUI.Interface.IpcApi/IpcOperationApi.cs @@ -404,8 +404,12 @@ private static IReadOnlyList GetRetryModes(AbstractOperation operation) } if ( - !packageOperation.Options.SkipHashCheck - && packageOperation.Package.Manager.Capabilities.CanSkipIntegrityChecks + PackageOperation.CanRetrySkippingIntegrityChecks( + packageOperation.Package.Manager, + packageOperation.Options, + packageOperation.Role, + packageOperation.WillRunElevated + ) ) { retryModes.Add("retry-no-hash-check"); diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetPkgOperationHelper.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetPkgOperationHelper.cs index 5b33c5d55e..99bca80631 100644 --- a/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetPkgOperationHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetPkgOperationHelper.cs @@ -292,8 +292,8 @@ operation is OperationType.Uninstall return OperationVeredict.AutoRetry; } - if (uintCode is 0x8A150011) - { // TODO: Integrity failed + if (ReportedInstallerHashMismatch(returnCode)) + { return OperationVeredict.Failure; } @@ -430,6 +430,9 @@ private static void RecordUpgradeAttempt(string id, string version) ); } + internal static bool ReportedInstallerHashMismatch(int returnCode) => + (uint)returnCode is 0x8A150011; + internal bool ReportedUpdateNotApplicable( IReadOnlyList processOutput, int returnCode diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/WinGet.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/WinGet.cs index add00707fa..1aa981fdad 100644 --- a/src/UniGetUI.PackageEngine.Managers.WinGet/WinGet.cs +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/WinGet.cs @@ -253,6 +253,12 @@ int returnCode && helper.ReportedUpdateNotApplicable(processOutput, returnCode); } + public bool ReportedInstallerHashMismatch(int returnCode) => + WinGetPkgOperationHelper.ReportedInstallerHashMismatch(returnCode); + + public bool HonorsIntegrityCheckSkipWhenElevated => + SelectedCliToolKind is WinGetCliToolKind.BundledPinget; + protected override IReadOnlyList FindPackages_UnSafe(string query) { return WinGetHelper.Instance.FindPackages_UnSafe(query); diff --git a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs index a416b7f0ff..bf990c0964 100644 --- a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs +++ b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs @@ -148,6 +148,34 @@ private bool RequiresAdminRights() => !Settings.Get(Settings.K.ProhibitElevation) && (Package.OverridenOptions.RunAsAdministrator is true || Options.RunAsAdministrator); + public virtual bool WillRunElevated => + CoreTools.IsAdministrator() || RequiresAdminRights(); + + public static bool CanRetrySkippingIntegrityChecks( + IPackageManager manager, + InstallOptions options, + OperationType role, + bool willRunElevated + ) + { + if (!manager.Capabilities.CanSkipIntegrityChecks || options.SkipHashCheck) + return false; + + if (role is OperationType.Uninstall) + return false; + + return !willRunElevated || IntegrityCheckSkipSurvivesElevation(manager); + } + + private static bool IntegrityCheckSkipSurvivesElevation(IPackageManager manager) + { +#if WINDOWS + if (manager is WinGet winget) + return winget.HonorsIntegrityCheckSkipWhenElevated; +#endif + return true; + } + protected override void ApplyRetryAction(string retryMode) { switch (retryMode) @@ -900,8 +928,12 @@ List Output ReturnCode ); - if (veredict is OperationVeredict.Failure && Role is OperationType.Update) - ExplainNotApplicableUpdate(Output, ReturnCode); + if (veredict is OperationVeredict.Failure) + { + if (Role is OperationType.Update) + ExplainNotApplicableUpdate(Output, ReturnCode); + ExplainInstallerHashMismatch(ReturnCode); + } return Task.FromResult(veredict); } @@ -922,6 +954,41 @@ private void ExplainNotApplicableUpdate(List output, int returnCode) #endif } + private void ExplainInstallerHashMismatch(int returnCode) + { +#if WINDOWS + if (Package.Manager is not WinGet winget) + return; + + if (!winget.ReportedInstallerHashMismatch(returnCode)) + return; + + var placeholders = new Dictionary { { "package", Package.Name } }; + + if (WillRunElevated && !winget.HonorsIntegrityCheckSkipWhenElevated) + { + Metadata.FailureMessage = CoreTools.Translate( + "The installer for {package} does not match the hash published in its manifest, and WinGet refuses to skip that check while running as administrator", + placeholders + ); + } + else if (Options.SkipHashCheck) + { + Metadata.FailureMessage = CoreTools.Translate( + "The installer for {package} does not match the hash published in its manifest; WinGet only skips that check once its InstallerHashOverride administrator setting is enabled", + placeholders + ); + } + else + { + Metadata.FailureMessage = CoreTools.Translate( + "The installer for {package} does not match the hash published in its manifest, so it was not run", + placeholders + ); + } +#endif + } + private static bool IsWinGetManager(IPackageManager manager) { #if WINDOWS diff --git a/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs index 72e5325c29..6983c68ea8 100644 --- a/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs @@ -2007,11 +2007,177 @@ public async Task WinGetUpdateFailureUnrelatedToApplicabilityKeepsTheDefaultMess Assert.DoesNotContain("may already be up to date", operation.Metadata.FailureMessage); } + [Fact] + public async Task WinGetInstallerHashMismatchExplainsTheAdminBlock() + { + var manager = new WinGet(); + SetCliToolKind(manager, WinGetCliToolKind.SystemWinGet); + var package = new PackageBuilder() + .WithManager(manager) + .WithId("XiaoweiCloud.CalendarTask") + .WithVersion("3.30.298.9128") + .WithNewVersion("3.30.299.9142") + .Build(); + using var operation = new VeredictProbingUpdateOperation(package, new InstallOptions()) + { + ElevationOverride = true, + }; + string defaultMessage = operation.Metadata.FailureMessage; + + var veredict = await operation.ProbeProcessVeredict(unchecked((int)0x8A150011), []); + + OperationAssert.HasVeredict(veredict, OperationVeredict.Failure); + Assert.NotEqual(defaultMessage, operation.Metadata.FailureMessage); + Assert.Contains("does not match the hash", operation.Metadata.FailureMessage); + Assert.Contains("running as administrator", operation.Metadata.FailureMessage); + Assert.False(operation.Metadata.FailureMessage.EndsWith('.')); + } + + [Fact] + public async Task WinGetInstallerHashMismatchPointsAtTheOverrideSettingWhenSkippingWasRequested() + { + var manager = new WinGet(); + SetCliToolKind(manager, WinGetCliToolKind.SystemWinGet); + var package = new PackageBuilder() + .WithManager(manager) + .WithId("XiaoweiCloud.CalendarTask") + .WithVersion("3.30.298.9128") + .WithNewVersion("3.30.299.9142") + .Build(); + using var operation = new VeredictProbingUpdateOperation( + package, + new InstallOptions { SkipHashCheck = true } + ) + { + ElevationOverride = false, + }; + + var veredict = await operation.ProbeProcessVeredict(unchecked((int)0x8A150011), []); + + OperationAssert.HasVeredict(veredict, OperationVeredict.Failure); + Assert.Contains("InstallerHashOverride", operation.Metadata.FailureMessage); + } + + [Fact] + public async Task WinGetInstallerHashMismatchExplainsTheFailureWithoutElevation() + { + var manager = new WinGet(); + SetCliToolKind(manager, WinGetCliToolKind.SystemWinGet); + var package = new PackageBuilder() + .WithManager(manager) + .WithId("XiaoweiCloud.CalendarTask") + .WithVersion("3.30.298.9128") + .WithNewVersion("3.30.299.9142") + .Build(); + using var operation = new VeredictProbingUpdateOperation(package, new InstallOptions()) + { + ElevationOverride = false, + }; + string defaultMessage = operation.Metadata.FailureMessage; + + var veredict = await operation.ProbeProcessVeredict(unchecked((int)0x8A150011), []); + + OperationAssert.HasVeredict(veredict, OperationVeredict.Failure); + Assert.NotEqual(defaultMessage, operation.Metadata.FailureMessage); + Assert.Contains("does not match the hash", operation.Metadata.FailureMessage); + Assert.DoesNotContain("running as administrator", operation.Metadata.FailureMessage); + } + + [Fact] + public async Task WinGetFailureUnrelatedToTheInstallerHashKeepsTheDefaultMessage() + { + var manager = new WinGet(); + var package = new PackageBuilder() + .WithManager(manager) + .WithId("XiaoweiCloud.CalendarTask") + .WithVersion("3.30.298.9128") + .WithNewVersion("3.30.299.9142") + .Build(); + using var operation = new VeredictProbingUpdateOperation(package, new InstallOptions()) + { + ElevationOverride = true, + }; + string defaultMessage = operation.Metadata.FailureMessage; + + var veredict = await operation.ProbeProcessVeredict(unchecked((int)0x8A150012), []); + + OperationAssert.HasVeredict(veredict, OperationVeredict.Failure); + Assert.Equal(defaultMessage, operation.Metadata.FailureMessage); + } + + [Fact] + public void WinGetDoesNotOfferTheIntegritySkipRetryWhenTheOperationRunsElevated() + { + var manager = new WinGet(); + SetCliToolKind(manager, WinGetCliToolKind.SystemWinGet); + + Assert.False( + PackageOperation.CanRetrySkippingIntegrityChecks( + manager, + new InstallOptions(), + OperationType.Update, + willRunElevated: true + ) + ); + Assert.True( + PackageOperation.CanRetrySkippingIntegrityChecks( + manager, + new InstallOptions(), + OperationType.Update, + willRunElevated: false + ) + ); + } + + [Fact] + public void WinGetOffersTheIntegritySkipRetryWhenElevatedOnPinget() + { + var manager = new WinGet(); + SetCliToolKind(manager, WinGetCliToolKind.BundledPinget); + + Assert.True( + PackageOperation.CanRetrySkippingIntegrityChecks( + manager, + new InstallOptions(), + OperationType.Update, + willRunElevated: true + ) + ); + } + + [Fact] + public void WinGetNeverOffersTheIntegritySkipRetryTwiceOrOnUninstall() + { + var manager = new WinGet(); + SetCliToolKind(manager, WinGetCliToolKind.BundledPinget); + + Assert.False( + PackageOperation.CanRetrySkippingIntegrityChecks( + manager, + new InstallOptions { SkipHashCheck = true }, + OperationType.Update, + willRunElevated: false + ) + ); + Assert.False( + PackageOperation.CanRetrySkippingIntegrityChecks( + manager, + new InstallOptions(), + OperationType.Uninstall, + willRunElevated: false + ) + ); + } + private sealed class VeredictProbingUpdateOperation : UpdatePackageOperation { public VeredictProbingUpdateOperation(IPackage package, InstallOptions options) : base(package, options) { } + public bool? ElevationOverride { get; set; } + + public override bool WillRunElevated => ElevationOverride ?? base.WillRunElevated; + public Task ProbeProcessVeredict(int returnCode, List output) => GetProcessVeredict(returnCode, output); } From db39d821e3be0379593324b3ed10586984c669f0 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Fri, 11 Sep 2026 08:59:33 -0400 Subject: [PATCH 2/4] Address review findings on the hash-mismatch explanation --- src/Languages/lang_en.json | 6 +-- .../OperationHistoryActionService.cs | 4 +- .../DialogPages/OperationViewModel.cs | 2 +- .../OperationFailedDialog.axaml.cs | 2 +- .../IpcOperationApi.cs | 1 - .../PackageOperations.cs | 52 +++++++++---------- .../WinGetManagerTests.cs | 29 +++++------ 7 files changed, 45 insertions(+), 51 deletions(-) diff --git a/src/Languages/lang_en.json b/src/Languages/lang_en.json index 1994397e90..0ff69b169d 100644 --- a/src/Languages/lang_en.json +++ b/src/Languages/lang_en.json @@ -1073,7 +1073,7 @@ "Settings imported": "Settings imported", "{0} file(s) were copied. Restart UniGetUI to apply them.": "{0} file(s) were copied. Restart UniGetUI to apply them.", "Could not import settings": "Could not import settings", - "The installer for {package} does not match the hash published in its manifest, and WinGet refuses to skip that check while running as administrator": "The installer for {package} does not match the hash published in its manifest, and WinGet refuses to skip that check while running as administrator", - "The installer for {package} does not match the hash published in its manifest; WinGet only skips that check once its InstallerHashOverride administrator setting is enabled": "The installer for {package} does not match the hash published in its manifest; WinGet only skips that check once its InstallerHashOverride administrator setting is enabled", - "The installer for {package} does not match the hash published in its manifest, so it was not run": "The installer for {package} does not match the hash published in its manifest, so it was not run" + "The installer for {package} does not match the hash in its manifest": "The installer for {package} does not match the hash in its manifest", + "The package manifest is likely out of date. WinGet cannot skip this check while running as administrator.": "The package manifest is likely out of date. WinGet cannot skip this check while running as administrator.", + "The package manifest is likely out of date. Skipping this check requires WinGet's InstallerHashOverride administrator setting.": "The package manifest is likely out of date. Skipping this check requires WinGet's InstallerHashOverride administrator setting." } diff --git a/src/UniGetUI.Avalonia/Infrastructure/OperationHistoryActionService.cs b/src/UniGetUI.Avalonia/Infrastructure/OperationHistoryActionService.cs index cf958c63a6..2c97cf8b0f 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/OperationHistoryActionService.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/OperationHistoryActionService.cs @@ -56,8 +56,8 @@ public static (bool AsAdmin, bool Interactive, bool SkipHash) GetRetryModes(Oper bool asAdmin = manager.Capabilities.CanRunAsAdmin && !options.RunAsAdministrator; bool interactive = manager.Capabilities.CanRunInteractively && !options.InteractiveInstallation; bool skipHash = PackageOperation.CanRetrySkippingIntegrityChecks( - manager, options, (OperationType)record.Role, - CoreTools.IsAdministrator() || options.RunAsAdministrator); + manager, options, CoreTools.IsAdministrator() || options.RunAsAdministrator) + && record.Role != (int)OperationType.Uninstall; return (asAdmin, interactive, skipHash); } diff --git a/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs index a5690fd5d8..32626d892e 100644 --- a/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs @@ -267,7 +267,7 @@ private void RebuildMenu(OperationStatus status) () => Operation.Retry(AbstractOperation.RetryMode.Retry_Interactive))); if (PackageOperation.CanRetrySkippingIntegrityChecks( - pkgOp.Package.Manager, pkgOp.Options, pkgOp.Role, pkgOp.WillRunElevated)) + pkgOp.Package.Manager, pkgOp.Options, pkgOp.WillRunElevated)) OpMenu.Items.Add(Item("Retry skipping integrity checks", "checksum.svg", true, () => Operation.Retry(AbstractOperation.RetryMode.Retry_SkipIntegrity))); } diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/OperationFailedDialog.axaml.cs b/src/UniGetUI.Avalonia/Views/DialogPages/OperationFailedDialog.axaml.cs index c9f55e05a3..5379d6fa60 100644 --- a/src/UniGetUI.Avalonia/Views/DialogPages/OperationFailedDialog.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/DialogPages/OperationFailedDialog.axaml.cs @@ -101,7 +101,7 @@ private Control BuildRetryButton(AbstractOperation operation) () => { operation.Retry(AbstractOperation.RetryMode.Retry_Interactive); Close(); })); if (PackageOperation.CanRetrySkippingIntegrityChecks( - pkgOp.Package.Manager, pkgOp.Options, pkgOp.Role, pkgOp.WillRunElevated)) + pkgOp.Package.Manager, pkgOp.Options, pkgOp.WillRunElevated)) retryOptions.Add(MenuItem(CoreTools.Translate("Retry skipping integrity checks"), () => { operation.Retry(AbstractOperation.RetryMode.Retry_SkipIntegrity); Close(); })); } diff --git a/src/UniGetUI.Interface.IpcApi/IpcOperationApi.cs b/src/UniGetUI.Interface.IpcApi/IpcOperationApi.cs index a9feb35d71..a666586b2d 100644 --- a/src/UniGetUI.Interface.IpcApi/IpcOperationApi.cs +++ b/src/UniGetUI.Interface.IpcApi/IpcOperationApi.cs @@ -407,7 +407,6 @@ private static IReadOnlyList GetRetryModes(AbstractOperation operation) PackageOperation.CanRetrySkippingIntegrityChecks( packageOperation.Package.Manager, packageOperation.Options, - packageOperation.Role, packageOperation.WillRunElevated ) ) diff --git a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs index bf990c0964..d65b559705 100644 --- a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs +++ b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs @@ -148,22 +148,25 @@ private bool RequiresAdminRights() => !Settings.Get(Settings.K.ProhibitElevation) && (Package.OverridenOptions.RunAsAdministrator is true || Options.RunAsAdministrator); + private volatile int _ranElevated = -1; + public virtual bool WillRunElevated => - CoreTools.IsAdministrator() || RequiresAdminRights(); + _ranElevated switch + { + 1 => true, + 0 => false, + _ => CoreTools.IsAdministrator() || RequiresAdminRights(), + }; public static bool CanRetrySkippingIntegrityChecks( IPackageManager manager, InstallOptions options, - OperationType role, bool willRunElevated ) { if (!manager.Capabilities.CanSkipIntegrityChecks || options.SkipHashCheck) return false; - if (role is OperationType.Uninstall) - return false; - return !willRunElevated || IntegrityCheckSkipSurvivesElevation(manager); } @@ -270,6 +273,8 @@ .. ElevatorArgumentPrefix(), process.StartInfo.StandardOutputEncoding = Package.Manager.OutputEncoding; process.StartInfo.StandardErrorEncoding = Package.Manager.OutputEncoding; + _ranElevated = IsAdmin ? 1 : 0; + ApplyCapabilities( IsAdmin, Options.InteractiveInstallation, @@ -339,6 +344,7 @@ private async Task PerformBrokerOperation() Package.Manager.OperationHelper.ApplyElevationRequirements(Package, Options, Role); bool requestElevated = RequiresAdminRights(); + _ranElevated = requestElevated ? 1 : 0; using var client = CreateBrokerClient(requestElevated); // Check broker availability. Brokered operations must not fall back to local @@ -963,29 +969,21 @@ private void ExplainInstallerHashMismatch(int returnCode) if (!winget.ReportedInstallerHashMismatch(returnCode)) return; - var placeholders = new Dictionary { { "package", Package.Name } }; + Metadata.FailureMessage = CoreTools.Translate( + "The installer for {package} does not match the hash in its manifest", + new Dictionary { { "package", Package.Name } } + ); - if (WillRunElevated && !winget.HonorsIntegrityCheckSkipWhenElevated) - { - Metadata.FailureMessage = CoreTools.Translate( - "The installer for {package} does not match the hash published in its manifest, and WinGet refuses to skip that check while running as administrator", - placeholders - ); - } - else if (Options.SkipHashCheck) - { - Metadata.FailureMessage = CoreTools.Translate( - "The installer for {package} does not match the hash published in its manifest; WinGet only skips that check once its InstallerHashOverride administrator setting is enabled", - placeholders - ); - } - else - { - Metadata.FailureMessage = CoreTools.Translate( - "The installer for {package} does not match the hash published in its manifest, so it was not run", - placeholders - ); - } + Line( + WillRunElevated && !winget.HonorsIntegrityCheckSkipWhenElevated + ? CoreTools.Translate( + "The package manifest is likely out of date. WinGet cannot skip this check while running as administrator." + ) + : CoreTools.Translate( + "The package manifest is likely out of date. Skipping this check requires WinGet's InstallerHashOverride administrator setting." + ), + LineType.Error + ); #endif } diff --git a/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs index 6983c68ea8..75d2b7e2f3 100644 --- a/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs @@ -2029,8 +2029,11 @@ public async Task WinGetInstallerHashMismatchExplainsTheAdminBlock() OperationAssert.HasVeredict(veredict, OperationVeredict.Failure); Assert.NotEqual(defaultMessage, operation.Metadata.FailureMessage); Assert.Contains("does not match the hash", operation.Metadata.FailureMessage); - Assert.Contains("running as administrator", operation.Metadata.FailureMessage); Assert.False(operation.Metadata.FailureMessage.EndsWith('.')); + Assert.Contains( + operation.GetOutput(), + line => line.Item1.Contains("cannot skip this check while running as administrator") + ); } [Fact] @@ -2055,7 +2058,10 @@ public async Task WinGetInstallerHashMismatchPointsAtTheOverrideSettingWhenSkipp var veredict = await operation.ProbeProcessVeredict(unchecked((int)0x8A150011), []); OperationAssert.HasVeredict(veredict, OperationVeredict.Failure); - Assert.Contains("InstallerHashOverride", operation.Metadata.FailureMessage); + Assert.Contains( + operation.GetOutput(), + line => line.Item1.Contains("InstallerHashOverride") + ); } [Fact] @@ -2080,7 +2086,10 @@ public async Task WinGetInstallerHashMismatchExplainsTheFailureWithoutElevation( OperationAssert.HasVeredict(veredict, OperationVeredict.Failure); Assert.NotEqual(defaultMessage, operation.Metadata.FailureMessage); Assert.Contains("does not match the hash", operation.Metadata.FailureMessage); - Assert.DoesNotContain("running as administrator", operation.Metadata.FailureMessage); + Assert.DoesNotContain( + operation.GetOutput(), + line => line.Item1.Contains("running as administrator") + ); } [Fact] @@ -2115,7 +2124,6 @@ public void WinGetDoesNotOfferTheIntegritySkipRetryWhenTheOperationRunsElevated( PackageOperation.CanRetrySkippingIntegrityChecks( manager, new InstallOptions(), - OperationType.Update, willRunElevated: true ) ); @@ -2123,7 +2131,6 @@ public void WinGetDoesNotOfferTheIntegritySkipRetryWhenTheOperationRunsElevated( PackageOperation.CanRetrySkippingIntegrityChecks( manager, new InstallOptions(), - OperationType.Update, willRunElevated: false ) ); @@ -2139,14 +2146,13 @@ public void WinGetOffersTheIntegritySkipRetryWhenElevatedOnPinget() PackageOperation.CanRetrySkippingIntegrityChecks( manager, new InstallOptions(), - OperationType.Update, willRunElevated: true ) ); } [Fact] - public void WinGetNeverOffersTheIntegritySkipRetryTwiceOrOnUninstall() + public void WinGetNeverOffersTheIntegritySkipRetryWhenAlreadySkipping() { var manager = new WinGet(); SetCliToolKind(manager, WinGetCliToolKind.BundledPinget); @@ -2155,15 +2161,6 @@ public void WinGetNeverOffersTheIntegritySkipRetryTwiceOrOnUninstall() PackageOperation.CanRetrySkippingIntegrityChecks( manager, new InstallOptions { SkipHashCheck = true }, - OperationType.Update, - willRunElevated: false - ) - ); - Assert.False( - PackageOperation.CanRetrySkippingIntegrityChecks( - manager, - new InstallOptions(), - OperationType.Uninstall, willRunElevated: false ) ); From 7a3a93e35815533195a454fcd28e8a6cbeda15f0 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Fri, 11 Sep 2026 09:18:46 -0400 Subject: [PATCH 3/4] Act on the PR review comments for the retry predicate --- .../OperationHistoryActionService.cs | 3 +- .../DialogPages/OperationViewModel.cs | 2 +- .../OperationFailedDialog.axaml.cs | 2 +- .../IpcOperationApi.cs | 1 + .../History/OperationHistoryRecord.cs | 6 +++ .../PackageOperations.cs | 12 ++++-- .../WinGetManagerTests.cs | 43 +++++++++++++++++++ 7 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/UniGetUI.Avalonia/Infrastructure/OperationHistoryActionService.cs b/src/UniGetUI.Avalonia/Infrastructure/OperationHistoryActionService.cs index 2c97cf8b0f..efff9d2b7d 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/OperationHistoryActionService.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/OperationHistoryActionService.cs @@ -56,7 +56,8 @@ public static (bool AsAdmin, bool Interactive, bool SkipHash) GetRetryModes(Oper bool asAdmin = manager.Capabilities.CanRunAsAdmin && !options.RunAsAdministrator; bool interactive = manager.Capabilities.CanRunInteractively && !options.InteractiveInstallation; bool skipHash = PackageOperation.CanRetrySkippingIntegrityChecks( - manager, options, CoreTools.IsAdministrator() || options.RunAsAdministrator) + manager, options, (OperationType)record.Role, + record.RanElevated ?? (CoreTools.IsAdministrator() || options.RunAsAdministrator)) && record.Role != (int)OperationType.Uninstall; return (asAdmin, interactive, skipHash); } diff --git a/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs index 32626d892e..a5690fd5d8 100644 --- a/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs @@ -267,7 +267,7 @@ private void RebuildMenu(OperationStatus status) () => Operation.Retry(AbstractOperation.RetryMode.Retry_Interactive))); if (PackageOperation.CanRetrySkippingIntegrityChecks( - pkgOp.Package.Manager, pkgOp.Options, pkgOp.WillRunElevated)) + pkgOp.Package.Manager, pkgOp.Options, pkgOp.Role, pkgOp.WillRunElevated)) OpMenu.Items.Add(Item("Retry skipping integrity checks", "checksum.svg", true, () => Operation.Retry(AbstractOperation.RetryMode.Retry_SkipIntegrity))); } diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/OperationFailedDialog.axaml.cs b/src/UniGetUI.Avalonia/Views/DialogPages/OperationFailedDialog.axaml.cs index 5379d6fa60..c9f55e05a3 100644 --- a/src/UniGetUI.Avalonia/Views/DialogPages/OperationFailedDialog.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/DialogPages/OperationFailedDialog.axaml.cs @@ -101,7 +101,7 @@ private Control BuildRetryButton(AbstractOperation operation) () => { operation.Retry(AbstractOperation.RetryMode.Retry_Interactive); Close(); })); if (PackageOperation.CanRetrySkippingIntegrityChecks( - pkgOp.Package.Manager, pkgOp.Options, pkgOp.WillRunElevated)) + pkgOp.Package.Manager, pkgOp.Options, pkgOp.Role, pkgOp.WillRunElevated)) retryOptions.Add(MenuItem(CoreTools.Translate("Retry skipping integrity checks"), () => { operation.Retry(AbstractOperation.RetryMode.Retry_SkipIntegrity); Close(); })); } diff --git a/src/UniGetUI.Interface.IpcApi/IpcOperationApi.cs b/src/UniGetUI.Interface.IpcApi/IpcOperationApi.cs index a666586b2d..a9feb35d71 100644 --- a/src/UniGetUI.Interface.IpcApi/IpcOperationApi.cs +++ b/src/UniGetUI.Interface.IpcApi/IpcOperationApi.cs @@ -407,6 +407,7 @@ private static IReadOnlyList GetRetryModes(AbstractOperation operation) PackageOperation.CanRetrySkippingIntegrityChecks( packageOperation.Package.Manager, packageOperation.Options, + packageOperation.Role, packageOperation.WillRunElevated ) ) diff --git a/src/UniGetUI.PackageEngine.Operations/History/OperationHistoryRecord.cs b/src/UniGetUI.PackageEngine.Operations/History/OperationHistoryRecord.cs index 53492bd829..7d69fb478c 100644 --- a/src/UniGetUI.PackageEngine.Operations/History/OperationHistoryRecord.cs +++ b/src/UniGetUI.PackageEngine.Operations/History/OperationHistoryRecord.cs @@ -38,6 +38,11 @@ public sealed class OperationHistoryRecord public string OptionsJson { get; set; } = ""; /// Process exit code, when the operation ran a process (null otherwise). public int? ExitCode { get; set; } + /// + /// Whether the operation actually ran elevated, for package operations. Null on records + /// written before this was tracked, and for operations that never ran a process. + /// + public bool? RanElevated { get; set; } /// Short human-readable reason, derived from the last error line (mainly for failures). public string FailureSummary { get; set; } = ""; public List Output { get; set; } = []; @@ -93,6 +98,7 @@ public static OperationHistoryRecord FromOperation(AbstractOperation op, string _ => pop.Package.VersionString, }; record.OptionsJson = pop.Options.AsJsonString(); + record.RanElevated = pop.WillRunElevated; break; case DownloadOperation dop: record.PackageId = dop.Package.Id; diff --git a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs index d65b559705..d0b3f1d717 100644 --- a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs +++ b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs @@ -161,20 +161,26 @@ private bool RequiresAdminRights() => public static bool CanRetrySkippingIntegrityChecks( IPackageManager manager, InstallOptions options, + OperationType role, bool willRunElevated ) { if (!manager.Capabilities.CanSkipIntegrityChecks || options.SkipHashCheck) return false; - return !willRunElevated || IntegrityCheckSkipSurvivesElevation(manager); + return IntegrityCheckSkipIsHonored(manager, role, willRunElevated); } - private static bool IntegrityCheckSkipSurvivesElevation(IPackageManager manager) + private static bool IntegrityCheckSkipIsHonored( + IPackageManager manager, + OperationType role, + bool willRunElevated + ) { #if WINDOWS if (manager is WinGet winget) - return winget.HonorsIntegrityCheckSkipWhenElevated; + return role is not OperationType.Uninstall + && (!willRunElevated || winget.HonorsIntegrityCheckSkipWhenElevated); #endif return true; } diff --git a/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs index 75d2b7e2f3..9d809f2a29 100644 --- a/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs @@ -2124,6 +2124,7 @@ public void WinGetDoesNotOfferTheIntegritySkipRetryWhenTheOperationRunsElevated( PackageOperation.CanRetrySkippingIntegrityChecks( manager, new InstallOptions(), + OperationType.Update, willRunElevated: true ) ); @@ -2131,6 +2132,7 @@ public void WinGetDoesNotOfferTheIntegritySkipRetryWhenTheOperationRunsElevated( PackageOperation.CanRetrySkippingIntegrityChecks( manager, new InstallOptions(), + OperationType.Update, willRunElevated: false ) ); @@ -2146,6 +2148,46 @@ public void WinGetOffersTheIntegritySkipRetryWhenElevatedOnPinget() PackageOperation.CanRetrySkippingIntegrityChecks( manager, new InstallOptions(), + OperationType.Update, + willRunElevated: true + ) + ); + } + + [Fact] + public void WinGetDoesNotOfferTheIntegritySkipRetryOnUninstall() + { + var manager = new WinGet(); + SetCliToolKind(manager, WinGetCliToolKind.SystemWinGet); + + Assert.False( + PackageOperation.CanRetrySkippingIntegrityChecks( + manager, + new InstallOptions(), + OperationType.Uninstall, + willRunElevated: false + ) + ); + Assert.DoesNotContain( + "--ignore-security-hash", + manager.OperationHelper.GetParameters( + new PackageBuilder().WithManager(manager).WithId("Contoso.Tool").Build(), + new InstallOptions { SkipHashCheck = true }, + OperationType.Uninstall + ) + ); + } + + [Fact] + public void OtherManagersKeepTheIntegritySkipRetryOnUninstall() + { + var manager = new Infrastructure.Fakes.TestPackageManager(); + + Assert.True( + PackageOperation.CanRetrySkippingIntegrityChecks( + manager, + new InstallOptions(), + OperationType.Uninstall, willRunElevated: true ) ); @@ -2161,6 +2203,7 @@ public void WinGetNeverOffersTheIntegritySkipRetryWhenAlreadySkipping() PackageOperation.CanRetrySkippingIntegrityChecks( manager, new InstallOptions { SkipHashCheck = true }, + OperationType.Update, willRunElevated: false ) ); From 5eee60bc949746ba3e4cd16d329816ccc889f81f Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Fri, 11 Sep 2026 09:39:11 -0400 Subject: [PATCH 4/4] Cover the persisted elevation flag with tests --- .../OperationHistoryTests.cs | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/UniGetUI.PackageEngine.Tests/OperationHistoryTests.cs b/src/UniGetUI.PackageEngine.Tests/OperationHistoryTests.cs index 64f0751ead..c077674db1 100644 --- a/src/UniGetUI.PackageEngine.Tests/OperationHistoryTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/OperationHistoryTests.cs @@ -1,4 +1,5 @@ using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; using UniGetUI.PackageEngine.Operations; using UniGetUI.PackageEngine.Operations.History; using UniGetUI.PackageEngine.Serializable; @@ -107,6 +108,7 @@ public void PersistsToDiskAndReloads() var record = Record("persisted"); record.Output.Add(new OperationHistoryOutputLine { Text = "line one", Type = "Information" }); record.Output.Add(new OperationHistoryOutputLine { Text = "boom", Type = "Error" }); + record.RanElevated = true; OperationHistoryStore.Add(record); // Drop the in-memory cache so the next read must deserialize the file. @@ -118,8 +120,51 @@ public void PersistsToDiskAndReloads() Assert.Equal(2, reloaded.Output.Count); Assert.Equal("boom", reloaded.Output[1].Text); Assert.Equal("Error", reloaded.Output[1].Type); + Assert.True(reloaded.RanElevated); } + [Fact] + public void PersistsANonElevatedRunAsFalseRatherThanUnknown() + { + var record = Record("standard"); + record.RanElevated = false; + OperationHistoryStore.Add(record); + OperationHistoryStore.InvalidateCache(); + + var reloaded = OperationHistoryStore.Get("standard"); + Assert.NotNull(reloaded); + Assert.False(reloaded!.RanElevated); + } + + [Fact] + public void RecordsWrittenBeforeElevationWasTrackedReloadAsUnknown() + { + File.WriteAllText(_tempFile, LegacyRecordJson); + OperationHistoryStore.InvalidateCache(); + + var reloaded = OperationHistoryStore.Get("legacy"); + Assert.NotNull(reloaded); + Assert.Equal("Contoso.Legacy", reloaded!.PackageId); + Assert.Null(reloaded.RanElevated); + } + + private const string LegacyRecordJson = """ + [ + { + "Id": "legacy", + "Kind": "install-package", + "Role": 0, + "PackageId": "Contoso.Legacy", + "PackageName": "Contoso Legacy", + "ManagerName": "winget", + "SourceName": "winget", + "Status": "succeeded", + "TimestampUtc": "2026-01-01T00:00:00.0000000Z", + "Output": [] + } + ] + """; + [Fact] public void CapsAtMaxEntries() { @@ -223,6 +268,37 @@ public void FromOperation_Install_CapturesKindRoleAndVersion() Assert.Equal(OperationHistoryRecord.StatusSucceeded, record.Status); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void FromOperation_CapturesTheElevationTheOperationRanWith(bool elevated) + { + var manager = new PackageManagerBuilder().WithName("Scoop").Build(); + var package = new PackageBuilder() + .WithManager(manager) + .WithId("Contoso.Tool") + .WithVersion("1.2.3") + .Build(); + + using var op = new ElevationStubInstallOperation(package, new InstallOptions(), elevated); + var record = OperationHistoryRecord.FromOperation(op, OperationHistoryRecord.StatusSucceeded); + + Assert.Equal(elevated, record.RanElevated); + } + + private sealed class ElevationStubInstallOperation : InstallPackageOperation + { + private readonly bool _elevated; + + public ElevationStubInstallOperation(IPackage package, InstallOptions options, bool elevated) + : base(package, options, IgnoreParallelInstalls: true) + { + _elevated = elevated; + } + + public override bool WillRunElevated => _elevated; + } + // The package a Discover install starts from carries the feed's LATEST version, while the // user may have pinned an older one in the install options. Recording the package version // then claims a version that was never installed - and the retry-from-history flow rebuilds