From 900a1d253c0ecf8cc4e0c7cd01d63e49d880b826 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Thu, 7 May 2026 14:13:02 -0400 Subject: [PATCH 1/3] add warning when the url change beween version for winget --- .../Models/PackageCollections.cs | 83 ++++++++++++++++ .../Views/Pages/SettingsPages/Updates.axaml | 5 + .../SoftwarePages/AbstractPackagesPage.axaml | 22 ++++- .../SettingsEngine_Names.cs | 2 + .../PingetPackageDetailsProvider.cs | 68 ++++++++++++- .../WinGet.cs | 15 +++ src/UniGetUI/Controls/PackageWrapper.cs | 97 +++++++++++++++++++ .../SettingsPages/GeneralPages/Updates.xaml | 13 ++- .../GeneralPages/Updates.xaml.cs | 3 - .../SoftwarePages/AbstractPackagesPage.xaml | 38 +++++++- 10 files changed, 337 insertions(+), 9 deletions(-) diff --git a/src/UniGetUI.Avalonia/Models/PackageCollections.cs b/src/UniGetUI.Avalonia/Models/PackageCollections.cs index 0a7401217c..8e45d76885 100644 --- a/src/UniGetUI.Avalonia/Models/PackageCollections.cs +++ b/src/UniGetUI.Avalonia/Models/PackageCollections.cs @@ -6,10 +6,12 @@ using Avalonia.Media.Imaging; using Avalonia.Threading; using UniGetUI.Avalonia.ViewModels.Pages; +using UniGetUI.Core.Logging; using UniGetUI.Core.SettingsEngine; using UniGetUI.Core.Tools; using UniGetUI.Interface.Enums; using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Managers.WingetManager; // ReSharper disable once CheckNamespace namespace UniGetUI.PackageEngine.PackageClasses; @@ -64,6 +66,11 @@ public bool IsChecked public string TagIconPath { get; private set; } = ""; public bool TagIconVisible { get; private set; } + public bool InstallerHostChanged { get; private set; } + public string InstallerHostChangeTooltip { get; private set; } = ""; + + private CancellationTokenSource? _installerHostCheckCts; + public string SourceIconPath => IconTypeToSvgPath(Package.Source.IconId); private static string IconTypeToSvgPath(IconType icon) @@ -94,6 +101,79 @@ public PackageWrapper(IPackage package, PackagesPageViewModel page) if (!Settings.Get(Settings.K.DisableIconsOnPackageLists)) _ = LoadIconAsync(); + + MaybeStartInstallerHostCheck(); + } + + /// + /// For upgradable WinGet packages, asynchronously fetches the installer URL host for + /// both the installed and the new version, and flags the row when the hosts differ. + /// See issue #4617 — defense-in-depth signal that an upgrade may be redirecting the + /// download to a different domain than the user originally trusted. + /// + private void MaybeStartInstallerHostCheck() + { + if (!Package.IsUpgradable) return; + if (Package.Manager is not WinGet) return; + if (Settings.Get(Settings.K.DisableInstallerHostChangeWarning)) return; + + string installedVersion = Package.VersionString; + string newVersion = Package.NewVersionString; + if (string.IsNullOrWhiteSpace(installedVersion) || string.IsNullOrWhiteSpace(newVersion)) + return; + if (installedVersion == newVersion) return; + + _installerHostCheckCts?.Cancel(); + _installerHostCheckCts = new CancellationTokenSource(); + CancellationToken token = _installerHostCheckCts.Token; + + _ = Task.Run(async () => + { + try + { + if (token.IsCancellationRequested) return; + var oldHosts = WinGet.TryGetInstallerHostsForVersion(Package, installedVersion); + if (token.IsCancellationRequested) return; + var newHosts = WinGet.TryGetInstallerHostsForVersion(Package, newVersion); + if (token.IsCancellationRequested) return; + + if (oldHosts is null || newHosts is null) return; + // Only flag when the two host sets are fully disjoint. If they share even + // one host, the publisher hasn't moved hosting — adding/removing CDN mirrors + // or architectures shouldn't trigger the warning. + if (oldHosts.Overlaps(newHosts)) return; + + string tooltip = CoreTools.Translate( + "Installer host changed since the installed version.\n" + + "Old: {0}\n" + + "New: {1}\n\n" + + "This is usually harmless (the publisher moved hosting), " + + "but can also indicate a hijacked package manifest. " + + "Verify the new source before upgrading.", + string.Join(", ", oldHosts), + string.Join(", ", newHosts) + ); + + await Dispatcher.UIThread.InvokeAsync(() => + { + if (token.IsCancellationRequested) return; + InstallerHostChanged = true; + InstallerHostChangeTooltip = tooltip; + PropertyChanged?.Invoke( + this, + new PropertyChangedEventArgs(nameof(InstallerHostChanged)) + ); + PropertyChanged?.Invoke( + this, + new PropertyChangedEventArgs(nameof(InstallerHostChangeTooltip)) + ); + }); + } + catch (Exception ex) + { + Logger.Warn($"Installer-host check failed for {Package.Id}: {ex.Message}"); + } + }, token); } private async Task LoadIconAsync() @@ -187,6 +267,9 @@ private void UpdateDisplayState() public void Dispose() { Package.PropertyChanged -= Package_PropertyChanged; + _installerHostCheckCts?.Cancel(); + _installerHostCheckCts?.Dispose(); + _installerHostCheckCts = null; } } diff --git a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Updates.axaml b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Updates.axaml index f2d6fd0640..1cdd3d3f99 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Updates.axaml +++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Updates.axaml @@ -81,6 +81,11 @@ BorderThickness="1,0,1,1" CornerRadius="0,0,8,8"/> + + diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml index 3a67da096c..6c46a45413 100644 --- a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml @@ -469,7 +469,13 @@ + Width="16" Height="16" VerticalAlignment="Center" + IsVisible="{Binding !InstallerHostChanged}"/> + @@ -528,6 +534,13 @@ IsVisible="{Binding TagIconVisible}" HorizontalAlignment="Right" VerticalAlignment="Bottom"/> + + "WinGetCliToolPreference", K.WinGetComApiPolicy => "WinGetComApiPolicy", K.DisableClassicMode => "DisableClassicMode", + K.DisableInstallerHostChangeWarning => "DisableInstallerHostChangeWarning", K.Test1 => "TestSetting1", K.Test2 => "TestSetting2", diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/PingetPackageDetailsProvider.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/PingetPackageDetailsProvider.cs index e3944879d4..4fde3193eb 100644 --- a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/PingetPackageDetailsProvider.cs +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/PingetPackageDetailsProvider.cs @@ -136,6 +136,12 @@ private static IReadOnlyList BuildShowArguments(PackageQuery query, stri arguments.Add(source); } + if (!string.IsNullOrWhiteSpace(query.Version)) + { + arguments.Add("--version"); + arguments.Add(query.Version); + } + arguments.Add("--accept-source-agreements"); arguments.Add("--output"); arguments.Add("json"); @@ -347,7 +353,7 @@ public bool LoadPackageDetails(IPackageDetails details, INativeTaskLogger logger } } - internal static PackageQuery CreateQuery(IPackage package) + internal static PackageQuery CreateQuery(IPackage package, string? version = null) { string id = package.Id.TrimEnd('…'); string name = package.Name.TrimEnd('…'); @@ -379,6 +385,11 @@ internal static PackageQuery CreateQuery(IPackage package) query = query with { Source = package.Source.Name }; } + if (!string.IsNullOrWhiteSpace(version)) + { + query = query with { Version = version }; + } + return query; } @@ -446,6 +457,61 @@ private static ShowResult ShowWithRepository(PackageQuery query) return repository.ShowFirstMatchAcrossSources(query); } + /// + /// Fetches the set of installer URL hosts for a specific version of a package. + /// Returns null if the manifest can't be loaded, OR a non-empty set otherwise. + /// Used to detect installer-host changes between installed and upgrade versions (issue #4617). + /// Returns the SET (not just one) because manifests typically have multiple installers + /// (per arch / locale / scope) — flagging on a single-installer comparison can produce + /// false positives when a publisher legitimately uses different CDNs per architecture + /// or adds/removes architectures across versions. + /// + internal static IReadOnlySet? TryGetInstallerHostsForVersion( + IPackage package, + string version + ) + { + try + { + PackageQuery query = CreateQuery(package, version); + ShowResult result; + using (Repository repository = OpenRepository()) + { + result = repository.ShowFirstMatchAcrossSources(query); + } + + // Pinget silently falls back to the latest manifest when the requested version + // isn't in the index (yanked / expired / never indexed). That fallback would + // make the host-change check return false-negatives, so reject any result whose + // manifest version doesn't match what we asked for. + string returnedVersion = result.Manifest.Version ?? ""; + if (!string.Equals(returnedVersion, version, StringComparison.OrdinalIgnoreCase)) + { + Logger.Info( + $"Pinget returned manifest version '{returnedVersion}' when '{version}' " + + $"was requested for {package.Id}; treating as not found" + ); + return null; + } + + HashSet hosts = new(StringComparer.OrdinalIgnoreCase); + foreach (Installer installer in result.Manifest.Installers) + { + if (TryCreateUri(installer.Url, out Uri? uri) && uri is not null) + hosts.Add(uri.Host); + } + + return hosts.Count > 0 ? hosts : null; + } + catch (Exception ex) + { + Logger.Warn( + $"Could not resolve installer hosts for {package.Id} version {version}: {ex.Message}" + ); + return null; + } + } + private static Repository OpenRepository() { return Repository.Open(CreateRepositoryOptions()); diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/WinGet.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/WinGet.cs index ab77f9e8a7..fa4f4a4c6e 100644 --- a/src/UniGetUI.PackageEngine.Managers.WinGet/WinGet.cs +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/WinGet.cs @@ -180,6 +180,21 @@ public static string GetProxyArgument() return $"--proxy {proxyUri.ToString().TrimEnd('/')}"; } + /// + /// Returns the set of installer URL hosts from the WinGet manifest for a specific + /// version of the given package, or null if it can't be resolved. Used for the + /// installer-host-change warning on the Updates page (issue #4617). + /// Returns a set (not a single host) so callers can do set-overlap comparison — + /// see PingetPackageDetailsProvider.TryGetInstallerHostsForVersion for rationale. + /// + public static IReadOnlySet? TryGetInstallerHostsForVersion( + UniGetUI.PackageEngine.Interfaces.IPackage package, + string version + ) + { + return PingetPackageDetailsProvider.TryGetInstallerHostsForVersion(package, version); + } + protected override IReadOnlyList FindPackages_UnSafe(string query) { return WinGetHelper.Instance.FindPackages_UnSafe(query); diff --git a/src/UniGetUI/Controls/PackageWrapper.cs b/src/UniGetUI/Controls/PackageWrapper.cs index 659bb579b1..8761fc4f09 100644 --- a/src/UniGetUI/Controls/PackageWrapper.cs +++ b/src/UniGetUI/Controls/PackageWrapper.cs @@ -1,14 +1,18 @@ using System.Collections.Concurrent; using System.ComponentModel; using System.Runtime.InteropServices; +using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml.Media.Imaging; using UniGetUI.Core.Classes; +using UniGetUI.Core.Logging; +using UniGetUI.Core.SettingsEngine; using UniGetUI.Core.Tools; using UniGetUI.Interface; using UniGetUI.Interface.Enums; using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Managers.WingetManager; namespace UniGetUI.PackageEngine.PackageClasses { @@ -60,6 +64,13 @@ public Uri? PackageIcon public readonly string ExtendedTooltip = ""; public float ListedOpacity = 1.0f; + public bool InstallerHostChanged { get; private set; } + public string InstallerHostChangeTooltip { get; private set; } = ""; + + public bool ShowUpgradeDownloadIcon => Package.IsUpgradable && !InstallerHostChanged; + + private CancellationTokenSource? _installerHostCheckCts; + public int NewVersionLabelWidth { get => Package.IsUpgradable ? 125 : 0; @@ -94,6 +105,89 @@ public PackageWrapper(IPackage package, AbstractPackagesPage page) $"{package.Name} ({package.Id} from {package.Source.AsString_DisplayName})"; else ExtendedTooltip = $"{package.Name} (from {package.Source.AsString_DisplayName})"; + + MaybeStartInstallerHostCheck(); + } + + /// + /// For upgradable WinGet packages, asynchronously fetches the installer URL host for + /// both the installed and the new version, and flags the row when the hosts differ. + /// See issue #4617 — defense-in-depth signal that an upgrade may be redirecting the + /// download to a different domain than the user originally trusted. + /// + private void MaybeStartInstallerHostCheck() + { + if (!Package.IsUpgradable) return; + if (Package.Manager is not WinGet) return; + if (Settings.Get(Settings.K.DisableInstallerHostChangeWarning)) return; + + string installedVersion = Package.VersionString; + string newVersion = Package.NewVersionString; + if (string.IsNullOrWhiteSpace(installedVersion) || string.IsNullOrWhiteSpace(newVersion)) + return; + if (installedVersion == newVersion) return; + + DispatcherQueue? dispatcher = _page.DispatcherQueue + ?? DispatcherQueue.GetForCurrentThread(); + if (dispatcher is null) return; + + _installerHostCheckCts?.Cancel(); + _installerHostCheckCts = new CancellationTokenSource(); + CancellationToken token = _installerHostCheckCts.Token; + + Task.Run(() => + { + try + { + if (token.IsCancellationRequested) return; + var oldHosts = WinGet.TryGetInstallerHostsForVersion(Package, installedVersion); + if (token.IsCancellationRequested) return; + var newHosts = WinGet.TryGetInstallerHostsForVersion(Package, newVersion); + if (token.IsCancellationRequested) return; + + if (oldHosts is null || newHosts is null) return; + // Only flag when the two host sets are fully disjoint. If they share even + // one host, the publisher hasn't moved hosting — adding/removing CDN mirrors + // or architectures shouldn't trigger the warning. + if (oldHosts.Overlaps(newHosts)) return; + + string tooltip = CoreTools.Translate( + "Installer host changed since the installed version.\n" + + "Old: {0}\n" + + "New: {1}\n\n" + + "This is usually harmless (the publisher moved hosting), " + + "but can also indicate a hijacked package manifest. " + + "Verify the new source before upgrading.", + string.Join(", ", oldHosts), + string.Join(", ", newHosts) + ); + + dispatcher.TryEnqueue(() => + { + if (token.IsCancellationRequested) return; + InstallerHostChanged = true; + InstallerHostChangeTooltip = tooltip; + PropertyChanged?.Invoke( + this, + new PropertyChangedEventArgs(nameof(InstallerHostChanged)) + ); + PropertyChanged?.Invoke( + this, + new PropertyChangedEventArgs(nameof(InstallerHostChangeTooltip)) + ); + PropertyChanged?.Invoke( + this, + new PropertyChangedEventArgs(nameof(ShowUpgradeDownloadIcon)) + ); + }); + } + catch (Exception ex) + { + Logger.Warn( + $"Installer-host check failed for {Package.Id}: {ex.Message}" + ); + } + }, token); } public void PackageItemContainer_DoubleTapped( @@ -157,6 +251,9 @@ public void Package_PropertyChanged(object? sender, PropertyChangedEventArgs e) public void Dispose() { Package.PropertyChanged -= Package_PropertyChanged; + _installerHostCheckCts?.Cancel(); + _installerHostCheckCts?.Dispose(); + _installerHostCheckCts = null; } /// diff --git a/src/UniGetUI/Pages/SettingsPages/GeneralPages/Updates.xaml b/src/UniGetUI/Pages/SettingsPages/GeneralPages/Updates.xaml index 0863133e97..9788b90468 100644 --- a/src/UniGetUI/Pages/SettingsPages/GeneralPages/Updates.xaml +++ b/src/UniGetUI/Pages/SettingsPages/GeneralPages/Updates.xaml @@ -88,15 +88,15 @@ @@ -107,6 +107,13 @@ + + + + + + + + Date: Thu, 7 May 2026 15:36:20 -0400 Subject: [PATCH 2/3] add text to the lang_en.json --- src/Languages/Data/TranslatedPercentages.json | 116 +++++++++--------- src/Languages/lang_en.json | 4 +- .../SettingsPages/GeneralPages/Updates.xaml | 2 +- 3 files changed, 62 insertions(+), 60 deletions(-) diff --git a/src/Languages/Data/TranslatedPercentages.json b/src/Languages/Data/TranslatedPercentages.json index 7138fe2c73..e02ee13c5d 100644 --- a/src/Languages/Data/TranslatedPercentages.json +++ b/src/Languages/Data/TranslatedPercentages.json @@ -1,61 +1,61 @@ { - "af": "100%", - "ar": "100%", - "be": "100%", - "bg": "100%", - "bn": "100%", - "da": "100%", - "el": "100%", - "eo": "100%", - "es": "100%", - "es-MX": "100%", - "et": "100%", - "fa": "100%", - "fil": "100%", - "gl": "100%", - "gu": "100%", - "he": "100%", - "hi": "100%", - "hr": "100%", - "hu": "100%", - "id": "100%", - "ja": "100%", - "ka": "100%", - "kn": "100%", - "ko": "100%", - "ku": "100%", - "lt": "100%", - "mk": "100%", - "mr": "100%", - "nb": "100%", - "nn": "100%", - "pt_PT": "100%", - "ru": "100%", - "sa": "100%", - "si": "100%", - "sk": "100%", - "sl": "100%", - "sr": "100%", - "ta": "100%", - "tl": "100%", - "th": "100%", - "tr": "100%", - "uk": "100%", - "ur": "100%", - "ca": "100%", - "cs": "100%", - "de": "100%", - "fi": "100%", - "fr": "100%", - "it": "100%", - "nl": "100%", - "pl": "100%", - "pt_BR": "100%", - "ro": "100%", - "sq": "100%", - "sv": "100%", - "vi": "100%", - "zh_CN": "100%", - "zh_TW": "100%", + "af": "99%", + "ar": "99%", + "be": "99%", + "bg": "99%", + "bn": "99%", + "da": "99%", + "el": "99%", + "eo": "99%", + "es": "99%", + "es-MX": "99%", + "et": "99%", + "fa": "99%", + "fil": "99%", + "gl": "99%", + "gu": "99%", + "he": "99%", + "hi": "99%", + "hr": "99%", + "hu": "99%", + "id": "99%", + "ja": "99%", + "ka": "99%", + "kn": "99%", + "ko": "99%", + "ku": "99%", + "lt": "99%", + "mk": "99%", + "mr": "99%", + "nb": "99%", + "nn": "99%", + "pt_PT": "99%", + "ru": "99%", + "sa": "99%", + "si": "99%", + "sk": "99%", + "sl": "99%", + "sr": "99%", + "ta": "99%", + "tl": "99%", + "th": "99%", + "tr": "99%", + "uk": "99%", + "ur": "99%", + "ca": "99%", + "cs": "99%", + "de": "99%", + "fi": "99%", + "fr": "99%", + "it": "99%", + "nl": "99%", + "pl": "99%", + "pt_BR": "99%", + "ro": "99%", + "sq": "99%", + "sv": "99%", + "vi": "99%", + "zh_CN": "99%", + "zh_TW": "99%", "en": "100%" } diff --git a/src/Languages/lang_en.json b/src/Languages/lang_en.json index 3dde6bf7e8..741f306617 100644 --- a/src/Languages/lang_en.json +++ b/src/Languages/lang_en.json @@ -808,5 +808,7 @@ "Error": "Error", "Log in failed: ": "Log in failed: ", "Log out failed: ": "Log out failed: ", - "Package backup settings": "Package backup settings" + "Package backup settings": "Package backup settings", + "Warn me when the installer URL host changes between the installed version and the new version (WinGet only)": "Warn me when the installer URL host changes between the installed version and the new version (WinGet only)", + "Installer host changed since the installed version.\nOld: {0}\nNew: {1}\n\nThis is usually harmless (the publisher moved hosting), but can also indicate a hijacked package manifest. Verify the new source before upgrading.": "Installer host changed since the installed version.\nOld: {0}\nNew: {1}\n\nThis is usually harmless (the publisher moved hosting), but can also indicate a hijacked package manifest. Verify the new source before upgrading." } diff --git a/src/UniGetUI/Pages/SettingsPages/GeneralPages/Updates.xaml b/src/UniGetUI/Pages/SettingsPages/GeneralPages/Updates.xaml index 9788b90468..17f81a0957 100644 --- a/src/UniGetUI/Pages/SettingsPages/GeneralPages/Updates.xaml +++ b/src/UniGetUI/Pages/SettingsPages/GeneralPages/Updates.xaml @@ -111,7 +111,7 @@ BorderThickness="1,0,1,1" CornerRadius="0,0,8,8" SettingName="DisableInstallerHostChangeWarning" - Text="Warn me when an upgrade's installer URL points to a different host than the installed version (WinGet only)" + Text="Warn me when the installer URL host changes between the installed version and the new version (WinGet only)" /> Date: Thu, 7 May 2026 15:57:22 -0400 Subject: [PATCH 3/3] Fix build for non-windows platform --- src/UniGetUI.Avalonia/Models/PackageCollections.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/UniGetUI.Avalonia/Models/PackageCollections.cs b/src/UniGetUI.Avalonia/Models/PackageCollections.cs index 8e45d76885..66e18c2655 100644 --- a/src/UniGetUI.Avalonia/Models/PackageCollections.cs +++ b/src/UniGetUI.Avalonia/Models/PackageCollections.cs @@ -11,7 +11,9 @@ using UniGetUI.Core.Tools; using UniGetUI.Interface.Enums; using UniGetUI.PackageEngine.Interfaces; +#if WINDOWS using UniGetUI.PackageEngine.Managers.WingetManager; +#endif // ReSharper disable once CheckNamespace namespace UniGetUI.PackageEngine.PackageClasses; @@ -113,6 +115,7 @@ public PackageWrapper(IPackage package, PackagesPageViewModel page) /// private void MaybeStartInstallerHostCheck() { +#if WINDOWS if (!Package.IsUpgradable) return; if (Package.Manager is not WinGet) return; if (Settings.Get(Settings.K.DisableInstallerHostChangeWarning)) return; @@ -174,6 +177,7 @@ await Dispatcher.UIThread.InvokeAsync(() => Logger.Warn($"Installer-host check failed for {Package.Id}: {ex.Message}"); } }, token); +#endif } private async Task LoadIconAsync()