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/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/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). 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(); + 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) + { + list.Add(ReadYamlNode(parser)); + } + 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 @@ -2241,6 +2307,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 +2466,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 +2592,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..cb79552 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()) } @@ -3556,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; @@ -3675,6 +3684,7 @@ fn collect_uninstall_view( correlated: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, }); } @@ -3740,6 +3750,7 @@ fn collect_appmodel_packages( correlated: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, }); } @@ -6637,6 +6648,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 +6697,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 +8275,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 +8303,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 +8505,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 +9421,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 +9464,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 +9506,7 @@ Installers: correlated: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, }; let candidates = vec![ SearchMatch { @@ -9360,6 +9555,7 @@ Installers: correlated: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, }; let candidates = vec![ SearchMatch { @@ -9421,6 +9617,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 +9656,7 @@ Installers: correlated: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, }; let candidates = vec![ SearchMatch { @@ -9505,6 +9703,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 +9735,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 +9776,7 @@ Installers: }), installed_version_canonical: false, correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, }; assert!(installed_package_has_upgrade(&package)); @@ -9606,6 +9807,7 @@ Installers: }), installed_version_canonical: false, correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, }; let query = ListQuery { upgrade_only: true, @@ -10029,6 +10231,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 +10247,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 +10263,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 +10554,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 +10589,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,11 +10624,13 @@ 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"); } + #[cfg(windows)] #[test] fn unflip_packed_guid_reverses_msi_installer_packing() { // The MSI Installer hive packs GUIDs by char-reversing each of the 11 @@ -10470,6 +10679,7 @@ Installers: }), installed_version_canonical: canonical, correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, } } @@ -10519,6 +10729,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); 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 }