From 690c526f58837a5bb02b40a87abc389ef21e86a3 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Wed, 13 May 2026 10:57:43 -0400 Subject: [PATCH 1/4] Fix uninstalling not working for some packages --- rust/crates/pinget-core/src/lib.rs | 17 +++++++++-- scripts/Parity-Compare-WingetParity.ps1 | 39 +++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/rust/crates/pinget-core/src/lib.rs b/rust/crates/pinget-core/src/lib.rs index 2aec677..6b661a0 100644 --- a/rust/crates/pinget-core/src/lib.rs +++ b/rust/crates/pinget-core/src/lib.rs @@ -6335,6 +6335,7 @@ fn uninstall_package(installed: &ListMatch, request: &UninstallRequest) -> Resul #[cfg(windows)] fn try_uninstall_arp(installed: &ListMatch, request: &UninstallRequest) -> Result> { + use std::os::windows::process::CommandExt; use std::process::Command; let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); @@ -6383,14 +6384,24 @@ fn try_uninstall_arp(installed: &ListMatch, request: &UninstallRequest) -> Resul return Ok(Some(exit_code)); } - let mut cmd = Command::new("cmd"); let log_path = request.log_path.as_ref().map(|value| value.display().to_string()); - cmd.arg("/C").arg(build_uninstall_command_with_mode( + let uninstall_line = build_uninstall_command_with_mode( &uninstall_cmd, request.mode, quiet_uninstall_cmd.is_some(), log_path.as_deref(), - )); + ); + // `UninstallString` is a complete command line and may already contain + // its own quotes (e.g. `"C:\path with spaces\unins.exe"`). Passing it + // through Command::arg causes Rust's CRT-style quoting to backslash-escape + // those embedded quotes (`\"...\"`), which cmd.exe does not understand and + // rejects with "is not recognized as an internal or external command". + // Use `cmd /S /C ""`: `/S` makes cmd strip only the outermost pair + // of quotes, leaving the embedded quotes intact. `raw_arg` bypasses Rust's + // automatic quoting so we can feed cmd the literal command line. + let mut cmd = Command::new("cmd"); + cmd.raw_arg("/S /C"); + cmd.raw_arg(format!("\"{uninstall_line}\"")); let status = cmd.status().context("failed to run uninstaller")?; return Ok(Some(status.code().unwrap_or(-1))); } diff --git a/scripts/Parity-Compare-WingetParity.ps1 b/scripts/Parity-Compare-WingetParity.ps1 index 0a27696..a119ffb 100644 --- a/scripts/Parity-Compare-WingetParity.ps1 +++ b/scripts/Parity-Compare-WingetParity.ps1 @@ -137,6 +137,35 @@ function Get-PackagedSettingsPath { return Join-Path $env:LOCALAPPDATA "Packages\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\LocalState\settings.json" } +function Test-IsPackagedProcess { + # GetCurrentPackageFullName returns APPMODEL_ERROR_NO_PACKAGE (15700) when the + # calling process has no AppX package identity. Pinget mirrors this check to + # decide whether to default its app root to the packaged WinGet LocalState + # (writeable only by packaged callers in practice) or to a non-packaged + # %LOCALAPPDATA%\Devolutions\Pinget fallback. + if (-not ('Pinget.AppModel' -as [type])) { + Add-Type -Namespace 'Pinget' -Name 'AppModel' -MemberDefinition @' +[System.Runtime.InteropServices.DllImport("kernel32.dll", CharSet=System.Runtime.InteropServices.CharSet.Unicode, ExactSpelling=true, SetLastError=false)] +public static extern int GetCurrentPackageFullName(ref uint packageFullNameLength, System.IntPtr packageFullName); +'@ + } + + [uint32]$length = 0 + $rc = [Pinget.AppModel]::GetCurrentPackageFullName([ref]$length, [System.IntPtr]::Zero) + return $rc -ne 15700 +} + +function Get-ExpectedUserSettingsPath { + # Matches default_app_root + user_settings_path in pinget-core: packaged + # callers write to %LOCALAPPDATA%\Packages\...\LocalState\settings.json; + # non-packaged callers write to %LOCALAPPDATA%\Devolutions\Pinget\user-settings.json. + if (Test-IsPackagedProcess) { + return Get-PackagedSettingsPath + } + + return Join-Path $env:LOCALAPPDATA "Devolutions\Pinget\user-settings.json" +} + function Test-IsProcessElevated { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = [Security.Principal.WindowsPrincipal]::new($identity) @@ -609,7 +638,8 @@ Assert-True -Condition (Test-Path -Path $PowerShellModulePath) -Message "Pinget Import-PingetPowerShellModule Invoke-Capture -Executable $SystemWinget -Arguments @("--version") | Out-Null -$settingsPath = Get-PackagedSettingsPath +$packagedSettingsPath = Get-PackagedSettingsPath +$settingsPath = Get-ExpectedUserSettingsPath $packagedLocalStateSegment = "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\LocalState" $settingsBackup = if (Test-Path -Path $settingsPath) { Get-Content -Path $settingsPath -Raw } else { $null } $packageId = $null @@ -626,7 +656,10 @@ try { $rustInfo = Invoke-Capture -Executable $RustWinget -Arguments @("--info") $dotnetInfo = Invoke-Capture -Executable $DotnetWinget -Arguments @("--info") $wingetInfo = Invoke-Capture -Executable $SystemWinget -Arguments @("--info") - Assert-Contains -Text $rustInfo.Output -Needle $settingsPath -Message "Rust pinget --info did not report the packaged settings path." + # `pinget --info` is winget-parity output and always reports the packaged + # LocalState path regardless of where pinget actually writes — assert against + # that fixed path, not the runtime-selected $settingsPath. + Assert-Contains -Text $rustInfo.Output -Needle $packagedSettingsPath -Message "Rust pinget --info did not report the packaged settings path." Assert-Contains -Text $dotnetInfo.Output -Needle "Pure C# subset of Pinget" -Message "C# pinget --info did not report the expected runtime banner." Assert-Contains -Text $wingetInfo.Output -Needle $packagedLocalStateSegment -Message "winget --info did not report the packaged LocalState path." Assert-True -Condition ($null -ne (Get-PowerShellSettingsObject)) -Message "PowerShell module could not read Pinget user settings." @@ -650,7 +683,7 @@ try { Write-Section "Settings roundtrip" $testSettings = New-TestSettingsHashtable Set-PingetUserSetting -UserSettings $testSettings -ErrorAction Stop | Out-Null - Assert-True -Condition (Test-Path -Path $settingsPath) -Message "PowerShell settings write did not create the packaged settings file." + Assert-True -Condition (Test-Path -Path $settingsPath) -Message "PowerShell settings write did not create the user settings file at '$settingsPath'." Assert-TestSettingsVisible -SettingsObject (Get-RustSettingsObject) -Label "Rust pinget settings export" Assert-TestSettingsVisible -SettingsObject (Get-DotnetSettingsObject) -Label "C# pinget settings export" Assert-TestSettingsVisible -SettingsObject (Get-PowerShellSettingsObject) -Label "Pinget PowerShell settings" From ece680967d316586775d3a94366705b690f1de49 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Wed, 13 May 2026 11:45:40 -0400 Subject: [PATCH 2/4] Fix some missing update --- .../CoreTests.cs | 137 +++++++++++ .../src/Devolutions.Pinget.Core/Repository.cs | 44 +++- rust/crates/pinget-core/src/lib.rs | 213 +++++++++++++++++- 3 files changed, 380 insertions(+), 14 deletions(-) diff --git a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs index ec78284..9f858a8 100644 --- a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs +++ b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs @@ -1288,6 +1288,143 @@ public void RegistryEntryMatchesInstalledPackage_UsesLocalIdentityInsteadOfCorre Assert.False(InstallerDispatch.RegistryEntryMatchesInstalledPackage("ShareX.ShareX", "ShareX.ShareX", null, installed)); } + [Fact] + public void CorrelateInstalledPackage_TiebreaksOnUnnormalizedNamePrefix() + { + // `Notepad++` and `Notepad--` both normalize to `notepad` (alphanumeric filter + // strips the trailing punctuation). Without a tiebreaker, iteration order + // decided the winner. The installed display name's prefix picks the correct one. + var installed = new InstalledPackage + { + Name = "Notepad++ (ARM 64-bit)", + LocalId = @"ARP\Machine\X64\Notepad++", + InstalledVersion = "8.8.9", + Scope = "Machine", + InstallerCategory = "exe", + PackageFamilyNames = [], + ProductCodes = [], + UpgradeCodes = [], + }; + var candidates = new List + { + new() + { + SourceName = "winget", + SourceKind = SourceKind.PreIndexed, + Id = "ndd.Notepad--", + Name = "Notepad--", + Version = "1.0.0", + }, + new() + { + SourceName = "winget", + SourceKind = SourceKind.PreIndexed, + Id = "Notepad++.Notepad++", + Name = "Notepad++", + Version = "8.9.5", + }, + }; + + var correlated = Repository.CorrelateInstalledPackage(installed, candidates, loose: true); + Assert.NotNull(correlated); + Assert.Equal("Notepad++.Notepad++", correlated!.Id); + } + + [Fact] + public void CorrelateInstalledPackage_PrefersAnchoredCandidateOverWordFragment() + { + // Loose substring match used to pick up `Studio` anywhere in the installed name + // — so `ZeroBrane.Studio` could outrank `Microsoft.DotNet.SDK.10` because both + // scored 700 and iteration order favored the alphabetically-later candidate. + var installed = new InstalledPackage + { + Name = "Microsoft .NET SDK 10.0.101 (arm64) from Visual Studio", + LocalId = @"ARP\Machine\X64\{7E9F8584-06E7-445E-9165-7486CC1B56C3}", + InstalledVersion = "40.10.18029", + Scope = "Machine", + InstallerCategory = "msi", + PackageFamilyNames = [], + ProductCodes = [], + UpgradeCodes = [], + }; + var candidates = new List + { + new() { SourceName = "winget", SourceKind = SourceKind.PreIndexed, Id = "ZeroBrane.Studio", Name = "Studio", Version = "1.0" }, + new() { SourceName = "winget", SourceKind = SourceKind.PreIndexed, Id = "BrickLink.Studio", Name = "Studio", Version = "1.0" }, + new() { SourceName = "winget", SourceKind = SourceKind.PreIndexed, Id = "Microsoft.DotNet.SDK.10", Name = "Microsoft .NET SDK 10.0", Version = "10.0.204" }, + }; + + var correlated = Repository.CorrelateInstalledPackage(installed, candidates, loose: true); + Assert.NotNull(correlated); + Assert.Equal("Microsoft.DotNet.SDK.10", correlated!.Id); + } + + [Fact] + public void CorrelateInstalledPackage_MsixCorrelatesByName() + { + // Previously hard-skipped via `LocalId.StartsWith("MSIX\\")` → null, which + // prevented obvious MSIX updates (Microsoft.Teams etc.) from ever surfacing. + // Name-based correlation now runs uniformly. + var installed = new InstalledPackage + { + Name = "Microsoft Teams", + LocalId = @"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe", + InstalledVersion = "25290.205.4069.4894", + Scope = "User", + InstallerCategory = "msix", + PackageFamilyNames = ["MSTeams_8wekyb3d8bbwe"], + ProductCodes = [], + UpgradeCodes = [], + }; + var candidates = new List + { + new() + { + SourceName = "winget", + SourceKind = SourceKind.PreIndexed, + Id = "Microsoft.Teams", + Name = "Microsoft Teams", + Version = "26106.1906.4665.7308", + }, + }; + + var correlated = Repository.CorrelateInstalledPackage(installed, candidates, loose: true); + Assert.NotNull(correlated); + Assert.Equal("Microsoft.Teams", correlated!.Id); + } + + [Fact] + public void CorrelateInstalledPackage_MsixWithResourceStringNameDoesNotCorrelate() + { + // Some MSIX entries have unresolved resource-string display names + // (e.g. `ms-resource:appDisplayName`). They shouldn't latch onto an + // unrelated catalog package via the loose substring rule. + var installed = new InstalledPackage + { + Name = "ms-resource:appDisplayName", + LocalId = @"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe", + InstalledVersion = "1.28.239.0", + Scope = "User", + InstallerCategory = "msix", + PackageFamilyNames = ["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe"], + ProductCodes = [], + UpgradeCodes = [], + }; + var candidates = new List + { + new() + { + SourceName = "winget", + SourceKind = SourceKind.PreIndexed, + Id = "Microsoft.AppInstaller", + Name = "App Installer", + Version = "1.28.240.0", + }, + }; + + Assert.Null(Repository.CorrelateInstalledPackage(installed, candidates, loose: true)); + } + [Fact] public void BuildUninstallCommand_AppendsSilentSwitchOnlyWhenNeeded() { diff --git a/dotnet/src/Devolutions.Pinget.Core/Repository.cs b/dotnet/src/Devolutions.Pinget.Core/Repository.cs index 66097ed..87c09d5 100644 --- a/dotnet/src/Devolutions.Pinget.Core/Repository.cs +++ b/dotnet/src/Devolutions.Pinget.Core/Repository.cs @@ -1778,12 +1778,16 @@ private List CorrelateAllInstalled(List installed) return warnings; } - private static SearchMatch? CorrelateInstalledPackage(InstalledPackage pkg, List candidates, bool loose) + internal static SearchMatch? CorrelateInstalledPackage(InstalledPackage pkg, List candidates, bool loose) { - if (pkg.LocalId.StartsWith(@"MSIX\", StringComparison.OrdinalIgnoreCase)) - return null; + // Note: MSIX packages used to be hard-skipped here, but that prevented obvious + // correlations like `Microsoft Teams` (MSIX) → `Microsoft.Teams` (catalog). + // Name-based correlation now applies uniformly; MSIX entries whose installed + // name is an unresolved resource string (e.g. `ms-resource:appDisplayName`) + // simply fail to match and return null, same as before. var installedName = NormalizeCorrelationName(pkg.Name); + var installedNameLower = pkg.Name.ToLowerInvariant(); var candidateNames = CorrelationNameCandidates(pkg.Name); SearchMatch? best = null; @@ -1791,16 +1795,40 @@ private List CorrelateAllInstalled(List installed) foreach (var candidate in candidates) { var candidateNorm = NormalizeCorrelationName(candidate.Name); - int score; + int baseScore; if (candidate.Id.Equals(pkg.LocalId, StringComparison.OrdinalIgnoreCase)) - score = 1000; + baseScore = 1000; else if (candidateNames.Any(n => NormalizeCorrelationName(n).Equals(candidateNorm, StringComparison.OrdinalIgnoreCase))) - score = 900; + baseScore = 900; else if (loose && candidateNorm.Length >= 6 && installedName.Contains(candidateNorm, StringComparison.OrdinalIgnoreCase)) - score = 700; + baseScore = 700; + else + baseScore = 0; + + if (baseScore == 0) continue; + + // Tiebreaker so we pick the *right* candidate when multiple catalog + // entries collapse to the same normalized name or all loose-match a + // long installed name. Two failure modes this addresses: + // • `Notepad++` and `Notepad--` both normalize to `notepad` (the + // alphanumeric filter strips `+`/`-`), so both score 900 and + // iteration order picked an arbitrary one. + // • Loose substring match picks up `Studio` inside + // `...from Visual Studio` and lets `ZeroBrane.Studio` outrank + // `Microsoft.DotNet.SDK.10` when both score 700. + // The bonus rewards catalog names that appear verbatim in the + // installed display name — strongly when they anchor the start, + // weaker when they're embedded. + var candidateLower = candidate.Name.ToLowerInvariant(); + int prefixBonus; + if (installedNameLower.StartsWith(candidateLower, StringComparison.Ordinal)) + prefixBonus = 100; + else if (installedNameLower.Contains(candidateLower, StringComparison.Ordinal)) + prefixBonus = 50; else - score = 0; + prefixBonus = 0; + var score = baseScore + prefixBonus; if (score > bestScore) { bestScore = score; best = candidate; } } return best; diff --git a/rust/crates/pinget-core/src/lib.rs b/rust/crates/pinget-core/src/lib.rs index 6b661a0..4991996 100644 --- a/rust/crates/pinget-core/src/lib.rs +++ b/rust/crates/pinget-core/src/lib.rs @@ -2642,31 +2642,62 @@ fn correlate_installed_package( candidates: &[SearchMatch], allow_loose_name_match: bool, ) -> Option { - if package.local_id.starts_with("MSIX\\") { - return None; - } + // Note: MSIX packages used to be hard-skipped here, but that prevented obvious + // correlations like `Microsoft Teams` (MSIX) → `Microsoft.Teams` (catalog). + // Name-based correlation now applies uniformly; MSIX entries whose installed + // name is an unresolved resource string (e.g. `ms-resource:appDisplayName`) + // simply fail to match and return None, same as before. let installed_name = normalize_correlation_name(&package.name); + let installed_name_lower = package.name.to_ascii_lowercase(); let candidate_names = correlation_name_candidates(&package.name); candidates .iter() .filter_map(|candidate| { let candidate_name = normalize_correlation_name(&candidate.name); - let score = if candidate.id.eq_ignore_ascii_case(&package.local_id) { + let base_score = if candidate.id.eq_ignore_ascii_case(&package.local_id) { 1000 } else if candidate_names.iter().any(|name| { let normalized = normalize_correlation_name(name); normalized == candidate_name }) { 900 - } else if allow_loose_name_match && candidate_name.len() >= 6 && installed_name.contains(&candidate_name) { + } else if allow_loose_name_match + && candidate_name.len() >= 6 + && installed_name.contains(&candidate_name) + { 700 } else { 0 }; - (score > 0).then_some((score, candidate.clone())) + if base_score == 0 { + return None; + } + + // Tiebreaker so we pick the *right* candidate when multiple catalog + // entries collapse to the same normalized name or all loose-match a + // long installed name. Two failure modes this addresses: + // • `Notepad++` and `Notepad--` both normalize to `notepad` (the + // alphanumeric filter strips `+`/`-`), so both score 900 and + // `max_by_key` arbitrarily picked the latter. + // • Loose substring match picks up `Studio` inside + // `...from Visual Studio` and lets `ZeroBrane.Studio` outrank + // `Microsoft.DotNet.SDK.10` when both score 700. + // The bonus rewards catalog names that appear verbatim in the + // installed display name — strongly when they anchor the start, + // weaker when they're embedded. + let candidate_lower = candidate.name.to_ascii_lowercase(); + let prefix_bonus = if installed_name_lower.starts_with(&candidate_lower) { + 100 + } else if installed_name_lower.contains(&candidate_lower) { + 50 + } else { + 0 + }; + + Some((base_score + prefix_bonus, candidate.clone())) }) .max_by_key(|(score, _)| *score) .map(|(_, candidate)| candidate) @@ -8193,6 +8224,176 @@ Installers: assert_eq!(package_query.count, Some(LIST_LOOKUP_MAX_RESULTS)); } + #[test] + fn correlation_tiebreaks_on_unnormalized_name_prefix() { + // `Notepad++` and `Notepad--` both normalize to `notepad` (alphanumeric filter + // strips the trailing punctuation). Without a tiebreaker, max_by_key returned + // whichever candidate happened to come last in iteration order. The installed + // display name's prefix decides the correct one. + let installed = InstalledPackage { + name: "Notepad++ (ARM 64-bit)".to_owned(), + local_id: r"ARP\Machine\X64\Notepad++".to_owned(), + installed_version: "8.8.9".to_owned(), + publisher: None, + scope: Some("Machine".to_owned()), + installer_category: Some("exe".to_owned()), + install_location: None, + package_family_names: Vec::new(), + product_codes: Vec::new(), + upgrade_codes: Vec::new(), + correlated: None, + }; + let candidates = vec![ + SearchMatch { + source_name: "winget".to_owned(), + source_kind: SourceKind::PreIndexed, + id: "ndd.Notepad--".to_owned(), + name: "Notepad--".to_owned(), + moniker: None, + version: Some("1.0.0".to_owned()), + channel: None, + match_criteria: None, + }, + SearchMatch { + source_name: "winget".to_owned(), + source_kind: SourceKind::PreIndexed, + id: "Notepad++.Notepad++".to_owned(), + name: "Notepad++".to_owned(), + moniker: None, + version: Some("8.9.5".to_owned()), + channel: None, + match_criteria: None, + }, + ]; + + let correlated = + correlate_installed_package(&installed, &candidates, true).expect("correlated"); + assert_eq!(correlated.id, "Notepad++.Notepad++"); + } + + #[test] + fn loose_correlation_prefers_anchored_candidate_over_word_fragment() { + // Without the prefix bonus, the loose substring match picked up `Studio` + // anywhere in the installed name — so `ZeroBrane.Studio` would outrank the + // genuinely-related `Microsoft.DotNet.SDK.10` because both scored 700 and + // sort order favored the alphabetically-later candidate. + let installed = InstalledPackage { + name: "Microsoft .NET SDK 10.0.101 (arm64) from Visual Studio".to_owned(), + local_id: r"ARP\Machine\X64\{7E9F8584-06E7-445E-9165-7486CC1B56C3}".to_owned(), + installed_version: "40.10.18029".to_owned(), + publisher: None, + scope: Some("Machine".to_owned()), + installer_category: Some("msi".to_owned()), + install_location: None, + package_family_names: Vec::new(), + product_codes: Vec::new(), + upgrade_codes: Vec::new(), + correlated: None, + }; + let candidates = vec![ + SearchMatch { + source_name: "winget".to_owned(), + source_kind: SourceKind::PreIndexed, + id: "ZeroBrane.Studio".to_owned(), + name: "Studio".to_owned(), + moniker: None, + version: Some("1.0".to_owned()), + channel: None, + match_criteria: None, + }, + SearchMatch { + source_name: "winget".to_owned(), + source_kind: SourceKind::PreIndexed, + id: "BrickLink.Studio".to_owned(), + name: "Studio".to_owned(), + moniker: None, + version: Some("1.0".to_owned()), + channel: None, + match_criteria: None, + }, + SearchMatch { + source_name: "winget".to_owned(), + source_kind: SourceKind::PreIndexed, + id: "Microsoft.DotNet.SDK.10".to_owned(), + name: "Microsoft .NET SDK 10.0".to_owned(), + moniker: None, + version: Some("10.0.204".to_owned()), + channel: None, + match_criteria: None, + }, + ]; + + let correlated = + correlate_installed_package(&installed, &candidates, true).expect("correlated"); + assert_eq!(correlated.id, "Microsoft.DotNet.SDK.10"); + } + + #[test] + fn msix_packages_correlate_by_name() { + // Previously hard-skipped via `package.local_id.starts_with("MSIX\\")` → + // None, which prevented obvious MSIX updates (Microsoft.Teams etc.) from + // ever surfacing. Name-based correlation now runs uniformly. + let installed = InstalledPackage { + name: "Microsoft Teams".to_owned(), + local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(), + installed_version: "25290.205.4069.4894".to_owned(), + publisher: None, + scope: Some("User".to_owned()), + installer_category: Some("msix".to_owned()), + install_location: None, + package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()], + product_codes: Vec::new(), + upgrade_codes: Vec::new(), + correlated: None, + }; + let candidates = vec![SearchMatch { + source_name: "winget".to_owned(), + source_kind: SourceKind::PreIndexed, + id: "Microsoft.Teams".to_owned(), + name: "Microsoft Teams".to_owned(), + moniker: None, + version: Some("26106.1906.4665.7308".to_owned()), + channel: None, + match_criteria: None, + }]; + + let correlated = + correlate_installed_package(&installed, &candidates, true).expect("correlated"); + assert_eq!(correlated.id, "Microsoft.Teams"); + } + + #[test] + fn msix_with_resource_string_name_does_not_correlate() { + // Some MSIX entries have unresolved resource-string display names + // (e.g. `ms-resource:appDisplayName`). Those shouldn't latch onto an + // unrelated catalog package via the loose substring rule. + let installed = InstalledPackage { + name: "ms-resource:appDisplayName".to_owned(), + local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(), + installed_version: "1.28.239.0".to_owned(), + publisher: None, + scope: Some("User".to_owned()), + installer_category: Some("msix".to_owned()), + install_location: None, + package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()], + product_codes: Vec::new(), + upgrade_codes: Vec::new(), + correlated: None, + }; + let candidates = vec![SearchMatch { + source_name: "winget".to_owned(), + source_kind: SourceKind::PreIndexed, + id: "Microsoft.AppInstaller".to_owned(), + name: "App Installer".to_owned(), + moniker: None, + version: Some("1.28.240.0".to_owned()), + channel: None, + match_criteria: None, + }]; + + assert!(correlate_installed_package(&installed, &candidates, true).is_none()); + } + #[test] fn strict_list_correlation_avoids_short_substring_matches() { let installed = InstalledPackage { From 139e63b38af8a7d8f2c73a9b1165db880043e9ab Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Wed, 13 May 2026 11:50:24 -0400 Subject: [PATCH 3/4] bump version to 0.4.2 --- dotnet/src/Devolutions.Pinget.Cli/Program.cs | 2 +- .../ModuleFiles/Devolutions.Pinget.Client.psd1 | 2 +- .../PowerShellEngineVersion.cs | 2 +- .../Devolutions.Pinget.Cli.DotNet.csproj | 2 +- .../Devolutions.Pinget.Cli.Rust.csproj | 2 +- rust/crates/pinget-cli/Cargo.toml | 4 ++-- rust/crates/pinget-com/Cargo.toml | 2 +- rust/crates/pinget-core/Cargo.toml | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/dotnet/src/Devolutions.Pinget.Cli/Program.cs b/dotnet/src/Devolutions.Pinget.Cli/Program.cs index 2090ad0..f3ec3c7 100644 --- a/dotnet/src/Devolutions.Pinget.Cli/Program.cs +++ b/dotnet/src/Devolutions.Pinget.Cli/Program.cs @@ -5,7 +5,7 @@ using Devolutions.Pinget.Core; using YamlDotNet.Serialization; -const string Version = "0.4.1"; +const string Version = "0.4.2"; const string UpgradeUnsupportedWarning = "Upgrading packages is not supported on this platform; no changes were made."; if (args.Length == 1 && (string.Equals(args[0], "--version", StringComparison.OrdinalIgnoreCase) || string.Equals(args[0], "-v", StringComparison.OrdinalIgnoreCase))) diff --git a/dotnet/src/Devolutions.Pinget.PowerShell.Cmdlets/ModuleFiles/Devolutions.Pinget.Client.psd1 b/dotnet/src/Devolutions.Pinget.PowerShell.Cmdlets/ModuleFiles/Devolutions.Pinget.Client.psd1 index f5b5760..82dc476 100644 --- a/dotnet/src/Devolutions.Pinget.PowerShell.Cmdlets/ModuleFiles/Devolutions.Pinget.Client.psd1 +++ b/dotnet/src/Devolutions.Pinget.PowerShell.Cmdlets/ModuleFiles/Devolutions.Pinget.Client.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'Devolutions.Pinget.Client.psm1' - ModuleVersion = '0.4.1' + ModuleVersion = '0.4.2' CompatiblePSEditions = @('Desktop', 'Core') GUID = 'c6d1b5f2-5ccd-4771-9480-25caad7c58bd' Author = 'Devolutions' diff --git a/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PowerShellEngineVersion.cs b/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PowerShellEngineVersion.cs index 2fd5580..13358fa 100644 --- a/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PowerShellEngineVersion.cs +++ b/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PowerShellEngineVersion.cs @@ -2,5 +2,5 @@ namespace Devolutions.Pinget.PowerShell.Engine; public static class PowerShellEngineVersion { - public const string Current = "0.4.1"; + public const string Current = "0.4.2"; } diff --git a/nuget/Devolutions.Pinget.Cli.DotNet/Devolutions.Pinget.Cli.DotNet.csproj b/nuget/Devolutions.Pinget.Cli.DotNet/Devolutions.Pinget.Cli.DotNet.csproj index 9cf6b2a..b5903af 100644 --- a/nuget/Devolutions.Pinget.Cli.DotNet/Devolutions.Pinget.Cli.DotNet.csproj +++ b/nuget/Devolutions.Pinget.Cli.DotNet/Devolutions.Pinget.Cli.DotNet.csproj @@ -1,7 +1,7 @@ - 0.4.1 + 0.4.2 Devolutions Inc. Devolutions Devolutions.Pinget.Cli.DotNet diff --git a/nuget/Devolutions.Pinget.Cli.Rust/Devolutions.Pinget.Cli.Rust.csproj b/nuget/Devolutions.Pinget.Cli.Rust/Devolutions.Pinget.Cli.Rust.csproj index a5fcbb1..13dac65 100644 --- a/nuget/Devolutions.Pinget.Cli.Rust/Devolutions.Pinget.Cli.Rust.csproj +++ b/nuget/Devolutions.Pinget.Cli.Rust/Devolutions.Pinget.Cli.Rust.csproj @@ -1,7 +1,7 @@ - 0.4.1 + 0.4.2 Devolutions Inc. Devolutions Devolutions.Pinget.Cli.Rust diff --git a/rust/crates/pinget-cli/Cargo.toml b/rust/crates/pinget-cli/Cargo.toml index 06fb96f..269e880 100644 --- a/rust/crates/pinget-cli/Cargo.toml +++ b/rust/crates/pinget-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pinget-cli" -version = "0.4.1" +version = "0.4.2" edition = "2024" [lints] @@ -13,7 +13,7 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.102" clap = { version = "4.6.1", features = ["derive"] } -pinget-core = { version = "0.4.1", path = "../pinget-core" } +pinget-core = { version = "0.4.2", path = "../pinget-core" } chrono = "0.4.44" dirs = "6.0" jsonschema = "0.30" diff --git a/rust/crates/pinget-com/Cargo.toml b/rust/crates/pinget-com/Cargo.toml index 871c113..15a63bd 100644 --- a/rust/crates/pinget-com/Cargo.toml +++ b/rust/crates/pinget-com/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pinget-com" -version = "0.4.1" +version = "0.4.2" edition = "2024" description = "Windows-only native COM bridge for Pinget backed by pinget-core." license = "MIT" diff --git a/rust/crates/pinget-core/Cargo.toml b/rust/crates/pinget-core/Cargo.toml index 4afc900..d711c51 100644 --- a/rust/crates/pinget-core/Cargo.toml +++ b/rust/crates/pinget-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pinget-core" -version = "0.4.1" +version = "0.4.2" edition = "2024" description = "Pure Rust Pinget core library that works directly with source caches, REST endpoints, and installed package state without COM." license = "MIT" From a903817d1428f376e64e867404b0d41af9225bb4 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Wed, 13 May 2026 13:15:26 -0400 Subject: [PATCH 4/4] chore: apply stable rustfmt to correlation fix --- rust/crates/pinget-core/src/lib.rs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/rust/crates/pinget-core/src/lib.rs b/rust/crates/pinget-core/src/lib.rs index 4991996..37fc207 100644 --- a/rust/crates/pinget-core/src/lib.rs +++ b/rust/crates/pinget-core/src/lib.rs @@ -2663,10 +2663,7 @@ fn correlate_installed_package( normalized == candidate_name }) { 900 - } else if allow_loose_name_match - && candidate_name.len() >= 6 - && installed_name.contains(&candidate_name) - { + } else if allow_loose_name_match && candidate_name.len() >= 6 && installed_name.contains(&candidate_name) { 700 } else { 0 @@ -8266,8 +8263,7 @@ Installers: }, ]; - let correlated = - correlate_installed_package(&installed, &candidates, true).expect("correlated"); + let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated"); assert_eq!(correlated.id, "Notepad++.Notepad++"); } @@ -8323,8 +8319,7 @@ Installers: }, ]; - let correlated = - correlate_installed_package(&installed, &candidates, true).expect("correlated"); + let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated"); assert_eq!(correlated.id, "Microsoft.DotNet.SDK.10"); } @@ -8357,8 +8352,7 @@ Installers: match_criteria: None, }]; - let correlated = - correlate_installed_package(&installed, &candidates, true).expect("correlated"); + let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated"); assert_eq!(correlated.id, "Microsoft.Teams"); }