From 763b89e086c76956da87886455185b41701d6988 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 19 May 2026 09:02:21 -0400 Subject: [PATCH 1/5] Filter upgrades to architectures the host can run winget hides upgrades whose latest catalog version has no installer for an architecture the host can execute. On arm64 Windows, x86 and x64 installers are runnable via emulation, so they stay; a manifest that only ships installers for an arch outside the host's preference list disappears from `upgrade` output entirely. --- .../CoreTests.cs | 65 ++++++ dotnet/src/Devolutions.Pinget.Core/Models.cs | 3 + .../src/Devolutions.Pinget.Core/Repository.cs | 26 ++- rust/crates/pinget-core/Cargo.toml | 2 +- rust/crates/pinget-core/src/lib.rs | 221 +++++++++++++++++- 5 files changed, 305 insertions(+), 12 deletions(-) diff --git a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs index 05cc958..0928da1 100644 --- a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs +++ b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs @@ -1764,6 +1764,71 @@ public void UpgradeFilter_HidesRequireExplicitUpgrade_ByDefault() Assert.True(Repository.InstalledPackageMatchesUpgradeFilterForTesting(pkg, bulkQuery)); } + [Fact] + public void UpgradeFilter_HidesLacksCompatibleInstaller_ByDefault() + { + var pkg = new InstalledPackage + { + Name = "Foo", + LocalId = @"ARP\Machine\X64\Foo", + InstalledVersion = "1.0", + Scope = "Machine", + InstallerCategory = "exe", + Correlated = new SearchMatch + { + SourceName = "winget", + SourceKind = SourceKind.PreIndexed, + Id = "Test.Foo", + Name = "Foo", + Version = "2.0", + }, + CorrelatedLacksCompatibleInstaller = true, + }; + + var bulkQuery = new ListQuery { UpgradeOnly = true }; + Assert.False(Repository.InstalledPackageMatchesUpgradeFilterForTesting(pkg, bulkQuery)); + + var filteredQuery = new ListQuery { UpgradeOnly = true, Id = "Test.Foo" }; + Assert.True(Repository.InstalledPackageMatchesUpgradeFilterForTesting(pkg, filteredQuery)); + + pkg.CorrelatedLacksCompatibleInstaller = false; + Assert.True(Repository.InstalledPackageMatchesUpgradeFilterForTesting(pkg, bulkQuery)); + } + + private static Manifest SyntheticManifestWithInstallerArches(params string[] arches) + { + var installers = arches.Select(a => new Installer { Architecture = a }).ToList(); + return new Manifest { Id = "Test", Name = "Test", Version = "1.0", Installers = installers }; + } + + [Fact] + public void ManifestHasCompatibleInstaller_NeutralAlwaysMatches() + { + Assert.True(Repository.ManifestHasCompatibleInstallerForTesting( + SyntheticManifestWithInstallerArches("neutral"))); + } + + [Fact] + public void ManifestHasCompatibleInstaller_EmptyInstallersPassThrough() + { + Assert.True(Repository.ManifestHasCompatibleInstallerForTesting( + SyntheticManifestWithInstallerArches())); + } + + [Fact] + public void ManifestHasCompatibleInstaller_RejectsAlienArchOnly() + { + Assert.False(Repository.ManifestHasCompatibleInstallerForTesting( + SyntheticManifestWithInstallerArches("ppc"))); + } + + [Fact] + public void ManifestHasCompatibleInstaller_MixedSetPassesIfAnyMatch() + { + Assert.True(Repository.ManifestHasCompatibleInstallerForTesting( + SyntheticManifestWithInstallerArches("ppc", "neutral"))); + } + [Fact] public void ApplyMsixResourceStringNameFix_ResolvesPlaceholderToCatalogName() { diff --git a/dotnet/src/Devolutions.Pinget.Core/Models.cs b/dotnet/src/Devolutions.Pinget.Core/Models.cs index d730efd..67ec7a2 100644 --- a/dotnet/src/Devolutions.Pinget.Core/Models.cs +++ b/dotnet/src/Devolutions.Pinget.Core/Models.cs @@ -528,6 +528,9 @@ internal record InstalledPackage // RequireExplicitUpgrade: true. winget hides those rows from bulk // `upgrade`; we mirror that. Users can still upgrade by explicit id. public bool CorrelatedRequiresExplicitUpgrade { get; set; } + // True when the correlated catalog package's latest version has no + // installer for an architecture the user can actually run. + public bool CorrelatedLacksCompatibleInstaller { get; set; } } internal enum SearchSemantics diff --git a/dotnet/src/Devolutions.Pinget.Core/Repository.cs b/dotnet/src/Devolutions.Pinget.Core/Repository.cs index 1501e07..13e5ff9 100644 --- a/dotnet/src/Devolutions.Pinget.Core/Repository.cs +++ b/dotnet/src/Devolutions.Pinget.Core/Repository.cs @@ -2241,6 +2241,9 @@ private static bool V2NormalizedIdentityTablesPresent(Microsoft.Data.Sqlite.Sqli internal static long? LookupUniqueNormalizedIdentityForTesting(Microsoft.Data.Sqlite.SqliteConnection conn, string normName, string normPublisher) => LookupUniqueNormalizedIdentity(conn, normName, normPublisher); + internal static bool ManifestHasCompatibleInstallerForTesting(Manifest manifest) + => ManifestHasCompatibleInstaller(manifest); + internal static bool InstalledPackageMatchesUpgradeFilterForTesting(InstalledPackage pkg, ListQuery query) => InstalledPackageMatchesUpgradeFilter(pkg, query); @@ -2397,6 +2400,7 @@ private List EnrichCorrelatedViaIndex(List installed, _client, "V2_M", source, latest.ManifestRelativePath, latest.ManifestHash); var manifest = ParseYamlManifest(bytes); installed[idx].CorrelatedRequiresExplicitUpgrade = manifest.RequireExplicitUpgrade; + installed[idx].CorrelatedLacksCompatibleInstaller = !ManifestHasCompatibleInstaller(manifest); } catch { /* best-effort */ } } @@ -2522,17 +2526,29 @@ private static bool ListPackageMatches(InstalledPackage pkg, ListQuery query) private static bool InstalledPackageMatchesUpgradeFilter(InstalledPackage pkg, ListQuery query) { - // Hide RequireExplicitUpgrade packages from bulk `upgrade` output to - // match winget (Edge, Steam, Discord, several MSIX packages). When - // the user explicitly filtered by id/name/etc., they're targeting - // a specific package and want to see it regardless — same allowance - // winget makes. + // Hide RequireExplicitUpgrade rows from bulk `upgrade` (Edge, Steam, + // Discord). Explicit `--id` filters surface them regardless. if (pkg.CorrelatedRequiresExplicitUpgrade && !ListQueryNeedsAvailableLookup(query)) return false; + // Hide rows whose latest catalog version has no installer the host + // can run — winget refuses to upgrade past an unusable architecture. + if (pkg.CorrelatedLacksCompatibleInstaller && !ListQueryNeedsAvailableLookup(query)) + return false; return InstalledPackageHasUpgrade(pkg) || (query.IncludeUnknown && InstalledPackageHasUnknownVersion(pkg) && pkg.Correlated is not null); } + private static bool ManifestHasCompatibleInstaller(Manifest manifest) + { + if (manifest.Installers.Count == 0) return true; + var allowed = PreferredArchitectures(CurrentArchitecture()); + return manifest.Installers.Any(installer => + { + var arch = installer.Architecture ?? "neutral"; + return allowed.Any(candidate => candidate.Equals(arch, StringComparison.OrdinalIgnoreCase)); + }); + } + internal static PinRecord? FindApplicablePin(ListMatch match, IReadOnlyList pins) { PinRecord? sourceSpecific = null; diff --git a/rust/crates/pinget-core/Cargo.toml b/rust/crates/pinget-core/Cargo.toml index adb23fa..481b5fc 100644 --- a/rust/crates/pinget-core/Cargo.toml +++ b/rust/crates/pinget-core/Cargo.toml @@ -30,4 +30,4 @@ zip = "8.5.1" [target.'cfg(windows)'.dependencies] winreg = "0.55.0" -windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_Packaging_Appx", "Win32_System_Threading"] } +windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_Packaging_Appx", "Win32_System_SystemInformation", "Win32_System_Threading"] } diff --git a/rust/crates/pinget-core/src/lib.rs b/rust/crates/pinget-core/src/lib.rs index 34fca7a..1b08cdc 100644 --- a/rust/crates/pinget-core/src/lib.rs +++ b/rust/crates/pinget-core/src/lib.rs @@ -581,6 +581,10 @@ struct InstalledPackage { // `RequireExplicitUpgrade: true`. winget hides those rows from bulk // `upgrade`; we mirror that. Users can still upgrade by explicit id. correlated_requires_explicit_upgrade: bool, + // True when the correlated catalog package's latest version has no + // installer for an architecture the user can actually run. winget + // hides those from `upgrade`; we mirror that. + correlated_lacks_compatible_installer: bool, } #[derive(Debug, Clone)] @@ -1460,6 +1464,8 @@ impl Repository { && let Ok(manifest) = parse_yaml_manifest(&bytes.bytes) { installed[idx].correlated_requires_explicit_upgrade = manifest.require_explicit_upgrade; + installed[idx].correlated_lacks_compatible_installer = + !manifest_has_compatible_installer(&manifest); } } } @@ -2865,14 +2871,16 @@ fn installed_package_has_unknown_version(package: &InstalledPackage) -> bool { } fn installed_package_matches_upgrade_filter(package: &InstalledPackage, query: &ListQuery) -> bool { - // Hide RequireExplicitUpgrade packages from bulk `upgrade` output to - // match winget (Edge, Steam, Discord, several MSIX packages). When - // the user explicitly filtered by id/name/etc., they're targeting a - // specific package and want to see it regardless — same allowance - // winget makes. + // Hide RequireExplicitUpgrade rows from bulk `upgrade` (Edge, Steam, + // Discord). Explicit `--id` filters surface them regardless. if package.correlated_requires_explicit_upgrade && !list_query_needs_available_lookup(query) { return false; } + // Hide rows whose latest catalog version has no installer the host can + // run — winget refuses to upgrade past an unusable architecture. + if package.correlated_lacks_compatible_installer && !list_query_needs_available_lookup(query) { + return false; + } installed_package_has_upgrade(package) || (query.include_unknown && installed_package_has_unknown_version(package) && package.correlated.is_some()) } @@ -3675,6 +3683,7 @@ fn collect_uninstall_view( correlated: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, }); } @@ -3740,6 +3749,7 @@ fn collect_appmodel_packages( correlated: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, }); } @@ -6637,6 +6647,20 @@ fn architecture_rank( .unwrap_or(-1) } +/// True when at least one installer in `manifest.installers` targets an +/// architecture the host can run. winget hides upgrades whose latest +/// version publishes no compatible installer. +fn manifest_has_compatible_installer(manifest: &Manifest) -> bool { + if manifest.installers.is_empty() { + return true; + } + let allowed = preferred_architectures(current_architecture()); + manifest.installers.iter().any(|installer| { + let arch = installer.architecture.as_deref().unwrap_or("neutral"); + allowed.iter().any(|candidate| candidate.eq_ignore_ascii_case(arch)) + }) +} + fn preferred_architectures(system_architecture: &str) -> &'static [&'static str] { match system_architecture { "arm64" => &["arm64", "neutral", "x64", "x86"], @@ -6672,7 +6696,41 @@ fn matches_optional_ci(value: Option<&str>, requested: Option<&str>) -> bool { } } +#[cfg(windows)] +fn current_architecture() -> &'static str { + use std::sync::OnceLock; + static CELL: OnceLock<&'static str> = OnceLock::new(); + CELL.get_or_init(|| { + // IsWow64Process2's `native_machine` is the OS architecture even + // when the process is running under emulation (x64 binary on + // arm64 Windows, x86 on x64). Compile-time `consts::ARCH` would + // mis-report the host arch in that case. + use windows_sys::Win32::System::Threading::{GetCurrentProcess, IsWow64Process2}; + let mut process_machine: u16 = 0; + let mut native_machine: u16 = 0; + // SAFETY: GetCurrentProcess returns a pseudo-handle that never fails; + // both out-pointers reference local stack u16s that outlive the call. + let handle = unsafe { GetCurrentProcess() }; + // SAFETY: handle is valid per above; pointers are valid. + let ok = unsafe { IsWow64Process2(handle, &mut process_machine, &mut native_machine) }; + if ok != 0 { + match native_machine { + 0xAA64 => return "arm64", + 0x8664 => return "x64", + 0x014C => return "x86", + _ => {} + } + } + compiled_architecture() + }) +} + +#[cfg(not(windows))] fn current_architecture() -> &'static str { + compiled_architecture() +} + +fn compiled_architecture() -> &'static str { match std::env::consts::ARCH { "x86_64" => "x64", "x86" => "x86", @@ -8216,6 +8274,7 @@ Installers: }), installed_version_canonical: false, correlated_requires_explicit_upgrade: true, + correlated_lacks_compatible_installer: false, }; let bulk_query = ListQuery { upgrade_only: true, @@ -8243,6 +8302,135 @@ Installers: assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query)); } + #[test] + fn upgrade_filter_hides_lacks_compatible_installer_by_default() { + let mut pkg = InstalledPackage { + name: "Foo".to_owned(), + local_id: r"ARP\Machine\X64\Foo".to_owned(), + installed_version: "1.0".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: Some(SearchMatch { + source_name: "winget".to_owned(), + source_kind: SourceKind::PreIndexed, + id: "Test.Foo".to_owned(), + name: "Foo".to_owned(), + moniker: None, + version: Some("2.0".to_owned()), + channel: None, + match_criteria: None, + }), + installed_version_canonical: false, + correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: true, + }; + let bulk_query = ListQuery { + upgrade_only: true, + ..ListQuery::default() + }; + assert!(!installed_package_matches_upgrade_filter(&pkg, &bulk_query)); + + // Explicit `--id` still surfaces it. + let filtered_query = ListQuery { + upgrade_only: true, + id: Some("Test.Foo".to_owned()), + ..ListQuery::default() + }; + assert!(installed_package_matches_upgrade_filter(&pkg, &filtered_query)); + + // Cleared flag → visible in bulk. + pkg.correlated_lacks_compatible_installer = false; + assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query)); + } + + fn synthetic_manifest_with_installer_arches(arches: &[&str]) -> Manifest { + let installers = arches + .iter() + .map(|a| Installer { + architecture: Some((*a).to_owned()), + installer_type: None, + url: None, + sha256: None, + product_code: None, + locale: None, + scope: None, + release_date: None, + package_family_name: None, + upgrade_code: None, + platforms: Vec::new(), + minimum_os_version: None, + switches: InstallerSwitches::default(), + commands: Vec::new(), + package_dependencies: Vec::new(), + require_explicit_upgrade: false, + }) + .collect(); + Manifest { + id: "Test".to_owned(), + name: "Test".to_owned(), + version: "1.0".to_owned(), + channel: String::new(), + publisher: None, + description: None, + moniker: None, + package_url: None, + publisher_url: None, + publisher_support_url: None, + license: None, + license_url: None, + privacy_url: None, + author: None, + copyright: None, + copyright_url: None, + release_notes: None, + release_notes_url: None, + tags: Vec::new(), + agreements: Vec::new(), + package_dependencies: Vec::new(), + documentation: Vec::new(), + installers, + require_explicit_upgrade: false, + } + } + + #[test] + fn manifest_compatible_installer_neutral_always_matches() { + // `neutral` is in every host's allowed list. + assert!(manifest_has_compatible_installer( + &synthetic_manifest_with_installer_arches(&["neutral"]) + )); + } + + #[test] + fn manifest_compatible_installer_empty_installers_pass_through() { + // Manifest with no installers (e.g. a docs-only entry): nothing to + // filter against, don't hide the row. + assert!(manifest_has_compatible_installer( + &synthetic_manifest_with_installer_arches(&[]) + )); + } + + #[test] + fn manifest_compatible_installer_rejects_alien_arch_only() { + // `ppc` is in no host's allowed list — manifest has nothing runnable. + assert!(!manifest_has_compatible_installer( + &synthetic_manifest_with_installer_arches(&["ppc"]) + )); + } + + #[test] + fn manifest_compatible_installer_mixed_set_passes_if_any_match() { + // Mixed list where at least `neutral` is in every host's preference. + assert!(manifest_has_compatible_installer( + &synthetic_manifest_with_installer_arches(&["ppc", "neutral"]) + )); + } + #[test] fn lookup_unique_normalized_identity_returns_unique_match() { // Single (norm_name, norm_publisher) intersection — happy path. @@ -8316,7 +8504,10 @@ Installers: .expect("seed schema"); let rowid = lookup_unique_normalized_identity(&connection, "foo", "bar").expect("query"); - assert!(rowid.is_none(), "name matches package 100 but publisher matches 200 — no intersection"); + assert!( + rowid.is_none(), + "name matches package 100 but publisher matches 200 — no intersection" + ); } #[test] @@ -9229,6 +9420,7 @@ Installers: correlated: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, }; let candidates = vec![SearchMatch { source_name: "winget".to_owned(), @@ -9271,6 +9463,7 @@ Installers: correlated: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, }; let query = ListQuery { tag: Some("powertoys".to_owned()), @@ -9312,6 +9505,7 @@ Installers: correlated: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, }; let candidates = vec![ SearchMatch { @@ -9360,6 +9554,7 @@ Installers: correlated: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, }; let candidates = vec![ SearchMatch { @@ -9421,6 +9616,7 @@ Installers: correlated: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, }; let candidates = vec![SearchMatch { source_name: "winget".to_owned(), @@ -9459,6 +9655,7 @@ Installers: correlated: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, }; let candidates = vec![ SearchMatch { @@ -9505,6 +9702,7 @@ Installers: correlated: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, }; let candidates = vec![SearchMatch { source_name: "winget".to_owned(), @@ -9536,6 +9734,7 @@ Installers: correlated: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, }; let candidates = vec![SearchMatch { source_name: "winget".to_owned(), @@ -9576,6 +9775,7 @@ Installers: }), installed_version_canonical: false, correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, }; assert!(installed_package_has_upgrade(&package)); @@ -9606,6 +9806,7 @@ Installers: }), installed_version_canonical: false, correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, }; let query = ListQuery { upgrade_only: true, @@ -10029,6 +10230,7 @@ Installers: correlated: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, }; let sparse = InstalledPackage { name: "PowerToys.SparseApp".to_owned(), @@ -10044,6 +10246,7 @@ Installers: correlated: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, }; let extension = InstalledPackage { name: "PowerToys FileLocksmith Context Menu".to_owned(), @@ -10059,6 +10262,7 @@ Installers: correlated: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, }; assert!(list_sort_weight(&main) < list_sort_weight(&sparse)); @@ -10349,6 +10553,7 @@ Installers: }), installed_version_canonical: false, correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, }; apply_msix_resource_string_name_fix(&mut package); assert_eq!(package.name, "App Installer"); @@ -10383,6 +10588,7 @@ Installers: }), installed_version_canonical: false, correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, }; apply_msix_resource_string_name_fix(&mut package); assert_eq!(package.name, "ms-resource:appDisplayName"); @@ -10417,6 +10623,7 @@ Installers: }), installed_version_canonical: false, correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, }; apply_msix_resource_string_name_fix(&mut package); assert_eq!(package.name, "Microsoft Teams"); @@ -10470,6 +10677,7 @@ Installers: }), installed_version_canonical: canonical, correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, } } @@ -10519,6 +10727,7 @@ Installers: correlated: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, }; let result = dedupe_correlated_for_upgrade(vec![uncorrelated]); assert_eq!(result.len(), 1); From c660344fed7629744d76c3b08b922b7d932098e7 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 19 May 2026 09:53:55 -0400 Subject: [PATCH 2/5] Fix parity test --- .github/workflows/parity-test.yml | 5 +++++ scripts/Parity-Compare-WingetParity.ps1 | 30 ++++++++++++++++++++++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/.github/workflows/parity-test.yml b/.github/workflows/parity-test.yml index 24d7ef3..a2e7341 100644 --- a/.github/workflows/parity-test.yml +++ b/.github/workflows/parity-test.yml @@ -55,7 +55,12 @@ jobs: - name: Run Windows parity coherence tests shell: pwsh + # Point pinget at winget's packaged LocalState so pin/source/settings + # stores are shared with the system winget. Without this, non-packaged + # pinget reads %LocalAppData%\Devolutions\Pinget\ and the cross-CLI + # coherence assertions cannot see each other's state. run: | + $env:PINGET_APPROOT = Join-Path $env:LOCALAPPDATA 'Packages\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\LocalState' $rustPinget = Resolve-Path 'rust/target/debug/pinget.exe' $dotnetPinget = Resolve-Path 'dotnet/src/Devolutions.Pinget.Cli/bin/Release/net10.0/pinget.exe' $pingetModule = Resolve-Path 'dist/powershell-module/Devolutions.Pinget.Client/Devolutions.Pinget.Client.psd1' diff --git a/scripts/Parity-Compare-WingetParity.ps1 b/scripts/Parity-Compare-WingetParity.ps1 index a119ffb..1c1c9f7 100644 --- a/scripts/Parity-Compare-WingetParity.ps1 +++ b/scripts/Parity-Compare-WingetParity.ps1 @@ -155,10 +155,34 @@ public static extern int GetCurrentPackageFullName(ref uint packageFullNameLengt return $rc -ne 15700 } +function Test-UsesPackagedLayout { + param([string]$AppRoot) + + # Mirrors uses_packaged_layout in pinget-core: a packaged WinGet app root + # is %LOCALAPPDATA%\Packages\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\LocalState. + if (-not $AppRoot) { return $false } + $leaf = Split-Path -Leaf $AppRoot + if ($leaf -ine "LocalState") { return $false } + $parent = Split-Path -Parent $AppRoot + if (-not $parent) { return $false } + $family = Split-Path -Leaf $parent + if ($family -ine "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe") { return $false } + $grand = Split-Path -Parent $parent + if (-not $grand) { return $false } + return ((Split-Path -Leaf $grand) -ieq "Packages") +} + 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. + # Matches default_app_root + user_settings_path in pinget-core: PINGET_APPROOT + # wins, then packaged callers write to %LOCALAPPDATA%\Packages\...\LocalState\settings.json, + # then non-packaged callers fall back to %LOCALAPPDATA%\Devolutions\Pinget\user-settings.json. + if ($env:PINGET_APPROOT) { + if (Test-UsesPackagedLayout -AppRoot $env:PINGET_APPROOT) { + return Join-Path $env:PINGET_APPROOT "settings.json" + } + return Join-Path $env:PINGET_APPROOT "user-settings.json" + } + if (Test-IsPackagedProcess) { return Get-PackagedSettingsPath } From 32a5a2f651e2d9f95df9f561214e245f1d2ad0d7 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 19 May 2026 10:33:53 -0400 Subject: [PATCH 3/5] Fix for failing check --- rust/crates/pinget-core/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rust/crates/pinget-core/src/lib.rs b/rust/crates/pinget-core/src/lib.rs index 1b08cdc..cb79552 100644 --- a/rust/crates/pinget-core/src/lib.rs +++ b/rust/crates/pinget-core/src/lib.rs @@ -3564,6 +3564,7 @@ fn collect_msi_upgrade_codes_from( /// registry hive back to the standard `{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}` /// lowercase form. The packing reverses each of the 11 chunks (sized 8/4/4 /// then eight 2-char byte pairs) of the GUID's hex representation. +#[cfg(windows)] fn unflip_packed_guid(packed: &str) -> Option { if packed.len() != 32 || !packed.chars().all(|c| c.is_ascii_hexdigit()) { return None; @@ -10629,6 +10630,7 @@ Installers: assert_eq!(package.name, "Microsoft Teams"); } + #[cfg(windows)] #[test] fn unflip_packed_guid_reverses_msi_installer_packing() { // The MSI Installer hive packs GUIDs by char-reversing each of the 11 From a47d6600a4fd4918db8425a724f46567004bfcfc Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 19 May 2026 10:51:03 -0400 Subject: [PATCH 4/5] Make manifest YAML and name normalization NativeAOT-safe Repository.ParseYamlManifest/ParseYamlManifestDocuments now walk YamlDotNet's IParser events directly instead of calling Deserialize/Deserialize>, which require dynamic code (IL3050) and trim-unsafe reflection (IL2026) under AOT. The walker produces the same Dictionary / List / string tree, so all downstream pattern matches and ReadBool calls work unchanged. NameNormalization is now a partial class with [GeneratedRegex] methods. RegexOptions.Compiled silently falls back to the interpreter under AOT, so the 32-regex hot path on ARP normalization would have regressed; source-generated regex restores native-quality performance and removes AOT warnings. --- .../NameNormalization.cs | 164 ++++++++++-------- .../src/Devolutions.Pinget.Core/Repository.cs | 90 ++++++++-- 2 files changed, 171 insertions(+), 83 deletions(-) diff --git a/dotnet/src/Devolutions.Pinget.Core/NameNormalization.cs b/dotnet/src/Devolutions.Pinget.Core/NameNormalization.cs index 6065778..4756fa1 100644 --- a/dotnet/src/Devolutions.Pinget.Core/NameNormalization.cs +++ b/dotnet/src/Devolutions.Pinget.Core/NameNormalization.cs @@ -15,7 +15,7 @@ namespace Devolutions.Pinget.Core; /// suffixes before comparing. This class reproduces those transformations /// so we can correlate the same set of ARP rows winget does. /// -internal static class NameNormalization +internal static partial class NameNormalization { internal enum Architecture { @@ -38,7 +38,7 @@ public static NormalizedName NormalizeName(string value) // SAP Business Object program names follow a specific pattern that // breaks under the regular flow; winget short-circuits them. - if (SapPackage.IsMatch(name)) + if (SapPackage().IsMatch(name)) { return new NormalizedName(name, Architecture.Unknown, string.Empty); } @@ -49,13 +49,13 @@ public static NormalizedName NormalizeName(string value) // Preserve KB numbers from within parens before the bracket strippers // would eat them — winget keeps `KB1234567` as part of the normalized // name because it's the only meaningful identifier on some patches. - name = KbNumbers.Replace(name, "$1"); + name = KbNumbers().Replace(name, "$1"); while (RemoveAll(ProgramNameRegexes, ref name)) { } - var tokens = SplitWithLegalSuffixExclusion(ProgramNameSplit, name, stopOnExclusion: false); + var tokens = SplitWithLegalSuffixExclusion(ProgramNameSplit(), name, stopOnExclusion: false); name = string.Concat(tokens); - name = NonLettersAndDigits.Replace(name, string.Empty); + name = NonLettersAndDigits().Replace(name, string.Empty); return new NormalizedName(name.ToLowerInvariant(), architecture, locale.ToLowerInvariant()); } @@ -77,9 +77,9 @@ public static string NormalizePublisher(string value) // Publisher split stops at the FIRST legal-entity suffix it sees // (after the first token), so "Foo Inc Internal Sub Bar" keeps just // "Foo" — "Inc" cuts off everything beyond. - var tokens = SplitWithLegalSuffixExclusion(PublisherNameSplit, publisher, stopOnExclusion: true); + var tokens = SplitWithLegalSuffixExclusion(PublisherNameSplit(), publisher, stopOnExclusion: true); publisher = string.Concat(tokens); - publisher = NonLettersAndDigits.Replace(publisher, string.Empty); + publisher = NonLettersAndDigits().Replace(publisher, string.Empty); return publisher.ToLowerInvariant(); } @@ -137,18 +137,18 @@ private static Architecture RemoveArchitecture(ref string value) { // Order matters: "32/64-bit" is a superstring of "64-bit"; "X64"/ // "AMD64" must beat "X32"/"X86" because of "x86-64". - if (Remove(Architecture32Or64Bit, ref value)) + if (Remove(Architecture32Or64Bit(), ref value)) return Architecture.Unknown; - if (Remove(ArchitectureX64, ref value) || Remove(Architecture64Bit, ref value)) + if (Remove(ArchitectureX64(), ref value) || Remove(Architecture64Bit(), ref value)) return Architecture.X64; - if (Remove(ArchitectureX32, ref value) || Remove(Architecture32Bit, ref value)) + if (Remove(ArchitectureX32(), ref value) || Remove(Architecture32Bit(), ref value)) return Architecture.X86; return Architecture.Unknown; } private static string RemoveLocale(ref string value) { - var matches = Locale.Matches(value); + var matches = Locale().Matches(value); if (matches.Count == 0) return string.Empty; var newValue = new System.Text.StringBuilder(value.Length); @@ -213,72 +213,94 @@ bool PushSegment(string segment) return result; } - // ── Regex patterns. .NET regex supports variable-length lookbehind - // directly, so the C++ patterns port over unchanged. ───────────────── - - private const RegexOptions Opts = RegexOptions.IgnoreCase | RegexOptions.Compiled; - - private static readonly Regex ArchitectureX32 = - new(@"(?<=^|[^\p{L}\p{Nd}])(X32|X86)(?=\P{Nd}|$)(?:\sEDITION)?", Opts); - private static readonly Regex ArchitectureX64 = - new(@"(?<=^|[^\p{L}\p{Nd}])(X64|AMD64|X86([\p{Pd}\p{Pc}]64))(?=\P{Nd}|$)(?:\sEDITION)?", Opts); - private static readonly Regex Architecture32Bit = - new(@"(?<=^|[^\p{L}\p{Nd}])(32[\p{Pd}\p{Pc}\p{Z}]?BIT)S?(?:\sEDITION)?", Opts); - private static readonly Regex Architecture64Bit = - new(@"(?<=^|[^\p{L}\p{Nd}])(64[\p{Pd}\p{Pc}\p{Z}]?BIT)S?(?:\sEDITION)?", Opts); - private static readonly Regex Architecture32Or64Bit = - new(@"(?<=^|[^\p{L}\p{Nd}])((64[\\/]32|32[\\/]64)[\p{Pd}\p{Pc}\p{Z}]?BIT)S?(?:\sEDITION)?", Opts); - - private static readonly Regex Locale = - new(@"(? versions, string? r internal static object ParseYamlManifestDocuments(byte[] bytes) { var yaml = System.Text.Encoding.UTF8.GetString(bytes); - var deserializer = new DeserializerBuilder() - .IgnoreUnmatchedProperties() - .Build(); var documents = new List>(); var parser = new YamlDotNet.Core.Parser(new StringReader(yaml)); parser.Consume(); while (parser.Accept(out _)) { - var doc = deserializer.Deserialize(parser); + var doc = ReadYamlDocument(parser); if (NormalizeYamlValue(doc) is Dictionary normalized) documents.Add(normalized); } @@ -1471,9 +1466,6 @@ internal static object ParseYamlManifestDocuments(byte[] bytes) internal static Manifest ParseYamlManifest(byte[] bytes) { var yaml = System.Text.Encoding.UTF8.GetString(bytes); - var deserializer = new DeserializerBuilder() - .IgnoreUnmatchedProperties() - .Build(); // Merge all YAML documents into one dictionary (manifests can be multi-document) var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -1481,11 +1473,13 @@ internal static Manifest ParseYamlManifest(byte[] bytes) parser.Consume(); while (parser.Accept(out _)) { - var doc = deserializer.Deserialize>(parser); - if (doc is not null) + if (ReadYamlDocument(parser) is Dictionary doc) { foreach (var kvp in doc) - dict[kvp.Key] = kvp.Value; + { + var keyStr = kvp.Key.ToString(); + if (keyStr is not null) dict[keyStr] = kvp.Value; + } } } @@ -1656,6 +1650,78 @@ private static bool ReadBool(IDictionary dict, string key) }; } + // Hand-rolled YAML node reader. Replaces YamlDotNet's generic Deserialize + // path, which is reflection-heavy and emits IL3050/IL2026 warnings under + // NativeAOT. Produces the same untyped object tree shape that + // `Deserialize` produces — Dictionary for + // mappings, List for sequences, string for scalars (or null for + // YAML null tags) — so all downstream pattern matches (`is IList`, + // `is IDictionary`) keep working unchanged. + private static object? ReadYamlDocument(YamlDotNet.Core.IParser parser) + { + parser.Consume(); + var value = ReadYamlNode(parser); + parser.Consume(); + return value; + } + + private static object? ReadYamlNode(YamlDotNet.Core.IParser parser) + { + var current = parser.Current; + switch (current) + { + case YamlDotNet.Core.Events.Scalar scalar: + parser.MoveNext(); + return ScalarToValue(scalar); + case YamlDotNet.Core.Events.MappingStart: + { + parser.MoveNext(); + var dict = new Dictionary(); + while (parser.Current is not YamlDotNet.Core.Events.MappingEnd) + { + var key = ReadYamlNode(parser); + var value = ReadYamlNode(parser); + if (key is not null) dict[key] = value ?? ""; + } + parser.MoveNext(); + return dict; + } + case YamlDotNet.Core.Events.SequenceStart: + { + parser.MoveNext(); + var list = new List(); + while (parser.Current is not YamlDotNet.Core.Events.SequenceEnd) + { + var item = ReadYamlNode(parser); + if (item is not null) list.Add(item); + } + parser.MoveNext(); + return list; + } + default: + parser.MoveNext(); + return null; + } + } + + private static object? ScalarToValue(YamlDotNet.Core.Events.Scalar scalar) + { + // Match YamlDotNet's plain-scalar null detection so manifest fields + // like `Moniker: ~` stay null instead of becoming "", which would + // make GetOptStr return "" where it used to return null. + if (scalar.Tag.IsEmpty && scalar.Style == YamlDotNet.Core.ScalarStyle.Plain) + { + var v = scalar.Value; + if (v.Length == 0 || v == "~" || + v.Equals("null", StringComparison.OrdinalIgnoreCase) || + v.Equals("Null", StringComparison.Ordinal)) + { + return null; + } + } + return scalar.Value; + } + private static object? NormalizeYamlValue(object? value) { return value switch From 2ab4d4004d00681c9a520a5f9f8437148702c3dc Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 19 May 2026 11:02:30 -0400 Subject: [PATCH 5/5] Preserve YAML nulls in walker collections --- .../src/Devolutions.Pinget.Core/Repository.cs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/dotnet/src/Devolutions.Pinget.Core/Repository.cs b/dotnet/src/Devolutions.Pinget.Core/Repository.cs index bebcfb7..ab41c00 100644 --- a/dotnet/src/Devolutions.Pinget.Core/Repository.cs +++ b/dotnet/src/Devolutions.Pinget.Core/Repository.cs @@ -1653,10 +1653,11 @@ private static bool ReadBool(IDictionary dict, string key) // Hand-rolled YAML node reader. Replaces YamlDotNet's generic Deserialize // path, which is reflection-heavy and emits IL3050/IL2026 warnings under // NativeAOT. Produces the same untyped object tree shape that - // `Deserialize` produces — Dictionary for - // mappings, List for sequences, string for scalars (or null for - // YAML null tags) — so all downstream pattern matches (`is IList`, - // `is IDictionary`) keep working unchanged. + // `Deserialize` produces — Dictionary for + // mappings, List for sequences, string for scalars (or null for + // YAML null tags). Nullability is compile-time only, so all downstream + // pattern matches (`is IList`, `is IDictionary`) + // keep working unchanged at runtime. private static object? ReadYamlDocument(YamlDotNet.Core.IParser parser) { parser.Consume(); @@ -1676,12 +1677,12 @@ private static bool ReadBool(IDictionary dict, string key) case YamlDotNet.Core.Events.MappingStart: { parser.MoveNext(); - var dict = new Dictionary(); + var dict = new Dictionary(); while (parser.Current is not YamlDotNet.Core.Events.MappingEnd) { var key = ReadYamlNode(parser); var value = ReadYamlNode(parser); - if (key is not null) dict[key] = value ?? ""; + if (key is not null) dict[key] = value; } parser.MoveNext(); return dict; @@ -1689,11 +1690,10 @@ private static bool ReadBool(IDictionary dict, string key) case YamlDotNet.Core.Events.SequenceStart: { parser.MoveNext(); - var list = new List(); + var list = new List(); while (parser.Current is not YamlDotNet.Core.Events.SequenceEnd) { - var item = ReadYamlNode(parser); - if (item is not null) list.Add(item); + list.Add(ReadYamlNode(parser)); } parser.MoveNext(); return list;