diff --git a/src/Languages/lang_en.json b/src/Languages/lang_en.json index bdb556e706..0ff69b169d 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 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 1c803466f2..efff9d2b7d 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/OperationHistoryActionService.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/OperationHistoryActionService.cs @@ -55,7 +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 + bool skipHash = PackageOperation.CanRetrySkippingIntegrityChecks( + 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 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/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 a416b7f0ff..d0b3f1d717 100644 --- a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs +++ b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs @@ -148,6 +148,43 @@ private bool RequiresAdminRights() => !Settings.Get(Settings.K.ProhibitElevation) && (Package.OverridenOptions.RunAsAdministrator is true || Options.RunAsAdministrator); + private volatile int _ranElevated = -1; + + public virtual bool WillRunElevated => + _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; + + return IntegrityCheckSkipIsHonored(manager, role, willRunElevated); + } + + private static bool IntegrityCheckSkipIsHonored( + IPackageManager manager, + OperationType role, + bool willRunElevated + ) + { +#if WINDOWS + if (manager is WinGet winget) + return role is not OperationType.Uninstall + && (!willRunElevated || winget.HonorsIntegrityCheckSkipWhenElevated); +#endif + return true; + } + protected override void ApplyRetryAction(string retryMode) { switch (retryMode) @@ -242,6 +279,8 @@ .. ElevatorArgumentPrefix(), process.StartInfo.StandardOutputEncoding = Package.Manager.OutputEncoding; process.StartInfo.StandardErrorEncoding = Package.Manager.OutputEncoding; + _ranElevated = IsAdmin ? 1 : 0; + ApplyCapabilities( IsAdmin, Options.InteractiveInstallation, @@ -311,6 +350,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 @@ -900,8 +940,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 +966,33 @@ 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; + + Metadata.FailureMessage = CoreTools.Translate( + "The installer for {package} does not match the hash in its manifest", + new Dictionary { { "package", Package.Name } } + ); + + 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 + } + private static bool IsWinGetManager(IPackageManager manager) { #if WINDOWS 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 diff --git a/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs index 72e5325c29..9d809f2a29 100644 --- a/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs @@ -2007,11 +2007,217 @@ 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.False(operation.Metadata.FailureMessage.EndsWith('.')); + Assert.Contains( + operation.GetOutput(), + line => line.Item1.Contains("cannot skip this check while running as administrator") + ); + } + + [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( + operation.GetOutput(), + line => line.Item1.Contains("InstallerHashOverride") + ); + } + + [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( + operation.GetOutput(), + line => line.Item1.Contains("running as administrator") + ); + } + + [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 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 + ) + ); + } + + [Fact] + public void WinGetNeverOffersTheIntegritySkipRetryWhenAlreadySkipping() + { + var manager = new WinGet(); + SetCliToolKind(manager, WinGetCliToolKind.BundledPinget); + + Assert.False( + PackageOperation.CanRetrySkippingIntegrityChecks( + manager, + new InstallOptions { SkipHashCheck = true }, + OperationType.Update, + 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); }