From 852ed8dd89c88d5ebbee3c1c9e0df361aefe1fb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Sun, 7 Jun 2026 21:03:10 -0400 Subject: [PATCH 1/6] Improve package listing parity Add a tri-comparison script for winget, C# Pinget, and Rust Pinget listing metadata. Fix shared listing correlation, source ordering, installed-db identity tracking, duplicate available versions, and MSIX split-resource suppression. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CoreTests.cs | 151 ++- .../InstalledPackages.cs | 35 +- .../src/Devolutions.Pinget.Core/Repository.cs | 276 ++++- .../Devolutions.Pinget.Core/SourceStore.cs | 9 + .../SystemWingetSourceStore.cs | 11 +- rust/crates/pinget-core/src/lib.rs | 767 ++++++++++++-- scripts/compare-list.ps1 | 977 ++++++++++++++++++ 7 files changed, 2122 insertions(+), 104 deletions(-) create mode 100644 scripts/compare-list.ps1 diff --git a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs index 4ed292f..73ed3e4 100644 --- a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs +++ b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs @@ -130,9 +130,12 @@ public void PackagedLayout_PathsResolveFromPackagedAppRoot() var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); var appRoot = Path.Combine(localAppData, "Packages", SourceStoreManager.PackagedFamilyName, "LocalState"); + var source = SourceStore.Default().Sources.First(s => s.Name == "winget"); Assert.EndsWith(Path.Combine("Packages", SourceStoreManager.PackagedFamilyName, "LocalState"), appRoot, StringComparison.OrdinalIgnoreCase); Assert.EndsWith(Path.Combine("Packages", SourceStoreManager.PackagedFamilyName, "LocalState", "settings.json"), SettingsStoreManager.UserSettingsPath(appRoot), StringComparison.OrdinalIgnoreCase); Assert.EndsWith(Path.Combine("Packages", SourceStoreManager.PackagedFamilyName, "LocalState", "Microsoft", "Windows Package Manager"), SourceStoreManager.GetPackagedFileCacheRoot(appRoot), StringComparison.OrdinalIgnoreCase); + Assert.EndsWith(Path.Combine("Packages", SourceStoreManager.PackagedFamilyName, "LocalState", "Microsoft.PreIndexed.Package", "Microsoft.Winget.Source_8wekyb3d8bbwe"), SourceStoreManager.SourceStateDir(source, appRoot), StringComparison.OrdinalIgnoreCase); + Assert.EndsWith(Path.Combine("Packages", SourceStoreManager.PackagedFamilyName, "LocalState", "Microsoft.Winget.Source_8wekyb3d8bbwe", "installed.db"), SourceStoreManager.SourceInstalledDbPath(source, appRoot), StringComparison.OrdinalIgnoreCase); } [Fact] @@ -192,24 +195,26 @@ public void PackagedSourceYaml_OverlaysDefaultsAndMetadata() public void SystemWingetExport_ParsesJsonLines() { const string output = """ -{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"} -{"Arg":"https://cdn.contoso.test/cache","Data":"Contoso.Source_8wekyb3d8bbwe","Explicit":true,"Identifier":"Contoso.Source_8wekyb3d8bbwe","Name":"contoso-cache","TrustLevel":["Trusted","StoreOrigin"],"Type":"Microsoft.PreIndexed.Package"} +{"Arg":"https://api.winget.pro/feed","Data":"","Explicit":false,"Identifier":"api.winget.pro","Name":"winget.pro","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"} +{"Arg":"https://storeedgefd.dsx.mp.microsoft.com/v9.0","Data":"","Explicit":false,"Identifier":"StoreEdgeFD","Name":"msstore","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"} +{"Arg":"https://cdn.winget.microsoft.com/cache","Data":"Microsoft.Winget.Source_8wekyb3d8bbwe","Explicit":false,"Identifier":"Microsoft.Winget.Source_8wekyb3d8bbwe","Name":"winget","TrustLevel":["Trusted","StoreOrigin"],"Type":"Microsoft.PreIndexed.Package"} +{"Arg":"https://cdn.winget.microsoft.com/fonts","Data":"Microsoft.Winget.Fonts.Source_8wekyb3d8bbwe","Explicit":true,"Identifier":"Microsoft.Winget.Fonts.Source_8wekyb3d8bbwe","Name":"winget-font","TrustLevel":["Trusted","StoreOrigin"],"Type":"Microsoft.PreIndexed.Package"} """; var sources = SystemWingetSourceStore.ParseExport(output); - Assert.Equal(["contoso", "contoso-cache"], sources.Select(source => source.Name).ToArray()); + Assert.Equal(["winget.pro", "msstore", "winget", "winget-font"], sources.Select(source => source.Name).ToArray()); var rest = sources[0]; Assert.Equal(SourceKind.Rest, rest.Kind); - Assert.Equal("https://api.contoso.test/feed", rest.Arg); - Assert.Equal("api.contoso.test", rest.Identifier); + Assert.Equal("https://api.winget.pro/feed", rest.Arg); + Assert.Equal("api.winget.pro", rest.Identifier); Assert.Equal("Trusted", rest.TrustLevel); Assert.False(rest.Explicit); - var preIndexed = sources[1]; + var preIndexed = sources[2]; Assert.Equal(SourceKind.PreIndexed, preIndexed.Kind); - Assert.Equal("Contoso.Source_8wekyb3d8bbwe", preIndexed.Identifier); - Assert.True(preIndexed.Explicit); + Assert.Equal("Microsoft.Winget.Source_8wekyb3d8bbwe", preIndexed.Identifier); + Assert.False(preIndexed.Explicit); } [Fact] @@ -1757,6 +1762,30 @@ public void LessThanLatestArpAnchoredVersion_HandlesCoarseDisplayVersion() Assert.Null(Repository.LessThanLatestArpAnchoredVersion(entries, "25.4.0")); } + [Fact] + public void GreaterThanLatestArpAnchoredVersion_HandlesServicedMsixFrameworkVersion() + { + // WindowsAppRuntime packages can be serviced beyond the latest winget + // catalog versionData range. winget renders those as `> latest` and + // does not report an available downgrade. + var entries = new List + { + new() { Version = "1.5.8", ArpMinVersion = "5001.373.1736.0", ArpMaxVersion = "5001.373.1736.0" }, + new() { Version = "1.5.7", ArpMinVersion = "5001.337.1906.0", ArpMaxVersion = "5001.337.1906.0" }, + }; + + Assert.Null(Repository.MapArpVersionToCatalog(entries, "5001.400.42.0")); + Assert.Null(Repository.LessThanLatestArpAnchoredVersion(entries, "5001.400.42.0")); + Assert.Equal("> 1.5.8", Repository.GreaterThanLatestArpAnchoredVersion(entries, "5001.400.42.0", "1.5.8")); + Assert.Null(Repository.GreaterThanLatestArpAnchoredVersion(entries, "5001.337.1906.0", "1.5.8")); + + var staleNormalAppEntries = new List + { + new() { Version = "2024.1.1", ArpMinVersion = "2024.1.1.0", ArpMaxVersion = "2024.1.1.0" }, + }; + Assert.Null(Repository.GreaterThanLatestArpAnchoredVersion(staleNormalAppEntries, "2026.1.3.0", "2026.2.0.0")); + } + [Fact] public void LatestArpAnchoredVersion_SkipsInternalRows() { @@ -1783,6 +1812,47 @@ public void LatestArpAnchoredVersion_ReturnsNullWhenNoBounds() Assert.Null(Repository.LatestArpAnchoredVersion(entries)); } + [Fact] + public void SuppressDuplicateAvailableVersions_KeepsAvailableOnlyOnNewestInstalledRow() + { + var rows = new List + { + new() + { + Name = "Example 1.0", + Id = "Example.Tool", + LocalId = @"ARP\Machine\X64\old", + InstalledVersion = "1.0.0", + AvailableVersion = "3.0.0", + SourceName = "winget", + }, + new() + { + Name = "Example 2.0", + Id = "Example.Tool", + LocalId = @"ARP\Machine\X64\new", + InstalledVersion = "2.0.0", + AvailableVersion = "3.0.0", + SourceName = "winget", + }, + new() + { + Name = "Other", + Id = "Other.Tool", + LocalId = @"ARP\Machine\X64\other", + InstalledVersion = "1.0.0", + AvailableVersion = "2.0.0", + SourceName = "winget", + }, + }; + + var result = Repository.SuppressDuplicateAvailableVersions(rows); + + Assert.Null(result[0].AvailableVersion); + Assert.Equal("3.0.0", result[1].AvailableVersion); + Assert.Equal("2.0.0", result[2].AvailableVersion); + } + [Fact] public void NormalizePublisher_Microsoft_Corporation_Strips_To_Microsoft() { @@ -2037,6 +2107,43 @@ public void LookupUniqueNormalizedIdentity_MissesWhenPublisherDoesNotMatch() Assert.Null(rowid); } + [Fact] + public void LookupInstalledDbIdentityMatch_ReturnsProductCodeMatch() + { + using var connection = new Microsoft.Data.Sqlite.SqliteConnection("Data Source=:memory:"); + connection.Open(); + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = @" + CREATE TABLE ids (rowid INTEGER PRIMARY KEY, id TEXT NOT NULL); + CREATE TABLE names (rowid INTEGER PRIMARY KEY, name TEXT NOT NULL); + CREATE TABLE manifest (rowid INTEGER PRIMARY KEY, id INT64 NOT NULL, name INT64 NOT NULL); + CREATE TABLE productcodes (rowid INTEGER PRIMARY KEY, productcode TEXT NOT NULL); + CREATE TABLE productcodes_map (manifest INT64 NOT NULL, productcode INT64 NOT NULL); + INSERT INTO ids VALUES (1, 'Git.Git'); + INSERT INTO names VALUES (1, 'Git'); + INSERT INTO manifest VALUES (10, 1, 1); + INSERT INTO productcodes VALUES (20, 'git_is1'); + INSERT INTO productcodes_map VALUES (10, 20);"; + cmd.ExecuteNonQuery(); + } + + var package = new InstalledPackage + { + Name = "Git", + LocalId = @"ARP\Machine\X64\Git_is1", + InstalledVersion = "2.54.0", + ProductCodes = ["git_is1"], + }; + + var hit = Repository.LookupInstalledDbIdentityMatchForTesting(connection, package); + + Assert.NotNull(hit); + Assert.Equal("Git.Git", hit.Value.Id); + Assert.Equal("Git", hit.Value.Name); + Assert.Equal("ProductCode", hit.Value.MatchedBy); + } + [Fact] public void UpgradeFilter_HidesRequireExplicitUpgrade_ByDefault() { @@ -2240,6 +2347,34 @@ public void UnflipPackedGuid_ReversesMsiInstallerPacking() Assert.Null(InstalledPackages.UnflipPackedGuid("ZZZZZZZZ593B4D2488740C8E00C4F652")); } + [Fact] + public void ParseMsixFullName_ReturnsBasePackageMetadata() + { + var parsed = InstalledPackages.ParseMsixFullName( + "Microsoft.XboxSpeechToTextOverlay_1.97.17002.0_x64__8wekyb3d8bbwe"); + + Assert.NotNull(parsed); + Assert.Equal("1.97.17002.0", parsed.Value.Version); + Assert.Equal("", parsed.Value.ResourceId); + Assert.Equal("Microsoft.XboxSpeechToTextOverlay_8wekyb3d8bbwe", parsed.Value.FamilyName); + Assert.False(InstalledPackages.IsMsixSplitResourcePackage(parsed.Value.ResourceId)); + } + + [Fact] + public void ParseMsixFullName_ClassifiesSplitResourcePackages() + { + var parsed = InstalledPackages.ParseMsixFullName( + "Microsoft.XboxSpeechToTextOverlay_1.97.17002.0_neutral_split.scale-125_8wekyb3d8bbwe"); + + Assert.NotNull(parsed); + Assert.Equal("1.97.17002.0", parsed.Value.Version); + Assert.Equal("split.scale-125", parsed.Value.ResourceId); + Assert.Equal( + "Microsoft.XboxSpeechToTextOverlay_split.scale-125_8wekyb3d8bbwe", + parsed.Value.FamilyName); + Assert.True(InstalledPackages.IsMsixSplitResourcePackage(parsed.Value.ResourceId)); + } + [Fact] public void DedupeCorrelatedForUpgrade_PrefersCanonicalRowOverRawArp() { diff --git a/dotnet/src/Devolutions.Pinget.Core/InstalledPackages.cs b/dotnet/src/Devolutions.Pinget.Core/InstalledPackages.cs index 7343d30..0bb4adc 100644 --- a/dotnet/src/Devolutions.Pinget.Core/InstalledPackages.cs +++ b/dotnet/src/Devolutions.Pinget.Core/InstalledPackages.cs @@ -279,16 +279,19 @@ private static void CollectAppModelPackages( if (subkey is null) continue; - var displayName = subkey.GetValue("DisplayName") as string; - if (string.IsNullOrWhiteSpace(displayName)) - continue; - var installLocation = subkey.GetValue("PackageRootFolder") as string; if (IsWindowsSystemPath(installLocation)) continue; var parsed = ParseMsixFullName(subkeyName); if (parsed is null) continue; + if (IsMsixSplitResourcePackage(parsed.Value.ResourceId)) continue; + + var displayName = subkey.GetValue("DisplayName") as string; + if (string.IsNullOrWhiteSpace(displayName)) + displayName = GetAppModelDisplayName(subkey); + if (string.IsNullOrWhiteSpace(displayName)) + continue; var localId = $@"MSIX\{subkeyName}"; var dedupKey = $"{localId}|{displayName.ToLowerInvariant()}|{parsed.Value.Version.ToLowerInvariant()}"; @@ -316,7 +319,25 @@ private static bool IsWindowsSystemPath(string? path) return path.Trim().StartsWith(@"C:\Windows\", StringComparison.OrdinalIgnoreCase); } - private static (string Version, string FamilyName)? ParseMsixFullName(string fullName) + [SupportedOSPlatform("windows")] + private static string? GetAppModelDisplayName(Microsoft.Win32.RegistryKey packageKey) + { + using var appKey = packageKey.OpenSubKey(@"App\Capabilities"); + if (appKey is null) return null; + + var description = appKey.GetValue("ApplicationDescription") as string; + if (!string.IsNullOrWhiteSpace(description)) + return description; + + var name = appKey.GetValue("ApplicationName") as string; + return string.IsNullOrWhiteSpace(name) ? null : name; + } + + internal static bool IsMsixSplitResourcePackage(string? resourceId) => + !string.IsNullOrWhiteSpace(resourceId) + && resourceId.StartsWith("split.", StringComparison.OrdinalIgnoreCase); + + internal static MsixPackageFullName? ParseMsixFullName(string fullName) { var parts = fullName.Split('_'); if (parts.Length < 5) return null; @@ -335,6 +356,8 @@ private static (string Version, string FamilyName)? ParseMsixFullName(string ful ? $"{name}_{publisherHash}" : $"{name}_{resourceId}_{publisherHash}"; - return (version, familyName); + return new MsixPackageFullName(version, resourceId, familyName); } + + internal readonly record struct MsixPackageFullName(string Version, string ResourceId, string FamilyName); } diff --git a/dotnet/src/Devolutions.Pinget.Core/Repository.cs b/dotnet/src/Devolutions.Pinget.Core/Repository.cs index 844fb44..5b10831 100644 --- a/dotnet/src/Devolutions.Pinget.Core/Repository.cs +++ b/dotnet/src/Devolutions.Pinget.Core/Repository.cs @@ -508,6 +508,8 @@ public CacheWarmResult WarmCache(PackageQuery query) public ListResponse List(ListQuery query) { + RefreshSystemWingetSources(); + if ((query.IncludeUnknown || query.IncludePinned) && !query.UpgradeOnly) throw new InvalidOperationException("--include-unknown and --include-pinned require --upgrade-available"); @@ -525,6 +527,7 @@ public ListResponse List(ListQuery query) // Even plain `list` should canonicalize installed package ids and // sources when the local package can be correlated back to a source // catalog entry. Available-version lookups stay gated below. + warnings.AddRange(CorrelateInstalledViaInstalledDb(installed, query.Source)); warnings.AddRange(CorrelateInstalledViaIndex(installed, query.Source)); warnings.AddRange(CorrelateInstalledByNormalizedIdentity(installed, query.Source)); @@ -585,6 +588,8 @@ public ListResponse List(ListQuery query) .Select(ListMatchFromInstalled) .Where(match => !query.UpgradeOnly || query.IncludePinned || !IsUpgradeBlockedByPin(match, pins)) .ToList(); + if (!query.UpgradeOnly) + listMatches = SuppressDuplicateAvailableVersions(listMatches); bool truncated = false; if (query.Count is int limit) @@ -2838,6 +2843,57 @@ private record V2PackageMetadata(long Rowid, string Id, string Name, string? Mon return $"< {latest.Version}"; } + /// + /// Returns "> latest>" when ARP range metadata proves the installed + /// package version is newer than the newest catalog version that has ARP + /// bounds. This is common for inbox MSIX frameworks such as + /// WindowsAppRuntime, where Windows can service a package beyond the + /// latest winget catalog mapping. + /// + internal static string? GreaterThanLatestArpAnchoredVersion( + IReadOnlyList entries, + string arpVersion, + string catalogLatestVersion) + { + if (string.IsNullOrEmpty(arpVersion) || arpVersion.Equals("Unknown", StringComparison.OrdinalIgnoreCase)) + return null; + + PreIndexedSource.V2VersionDataEntry? latest = null; + foreach (var entry in entries) + { + if (entry.ArpMinVersion is null || entry.ArpMaxVersion is null) continue; + if (latest is null || RestSource.CompareVersionStrings(entry.Version, latest.Version) > 0) + latest = entry; + } + if (latest?.ArpMaxVersion is null) + return null; + if (!latest.Version.Equals(catalogLatestVersion, StringComparison.OrdinalIgnoreCase)) + return null; + if (!HasDifferentLeadingVersionNumber(latest.ArpMaxVersion, latest.Version)) + return null; + if (RestSource.CompareVersionStrings(arpVersion, latest.ArpMaxVersion) <= 0) + return null; + + return $"> {latest.Version}"; + } + + private static bool HasDifferentLeadingVersionNumber(string left, string right) + { + var leftLeading = LeadingVersionNumber(left); + var rightLeading = LeadingVersionNumber(right); + return leftLeading is not null && rightLeading is not null && + !leftLeading.Equals(rightLeading, StringComparison.OrdinalIgnoreCase); + } + + private static string? LeadingVersionNumber(string value) + { + var trimmed = value.TrimStart(); + int length = 0; + while (length < trimmed.Length && char.IsAsciiDigit(trimmed[length])) + length++; + return length == 0 ? null : trimmed[..length]; + } + /// /// Returns the latest catalog Version that carries ARP-range metadata. /// Packages like `Microsoft.WindowsAppRuntime.1.8` also publish "internal" @@ -2859,6 +2915,151 @@ private record V2PackageMetadata(long Rowid, string Id, string Name, string? Mon return best?.Version; } + /// + /// Uses winget's per-source installed tracking DB when available. This is + /// an exact identity signal recorded by winget itself, and it preserves + /// source order for sources that do not expose a preindexed identity table + /// such as REST sources. + /// + private List CorrelateInstalledViaInstalledDb(List installed, string? requestedSource) + { + var warnings = new List(); + for (int sourceIndex = 0; sourceIndex < _store.Sources.Count; sourceIndex++) + { + var source = _store.Sources[sourceIndex]; + if (requestedSource is not null && !source.Name.Equals(requestedSource, StringComparison.OrdinalIgnoreCase)) + continue; + + var installedDbPath = SourceStoreManager.SourceInstalledDbPath(source, _appRoot); + if (!File.Exists(installedDbPath)) + continue; + + using var conn = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={installedDbPath};Mode=ReadOnly;Pooling=False"); + conn.Open(); + if (!InstalledDbIdentityTablesPresent(conn)) + continue; + + for (int i = 0; i < installed.Count; i++) + { + if (installed[i].Correlated is not null) continue; + var hit = LookupInstalledDbIdentityMatch(conn, installed[i]); + if (hit is null) continue; + + string? version = null; + string? moniker = null; + if (source.Kind == SourceKind.PreIndexed) + { + try + { + using var indexConn = OpenPreindexedConnection(sourceIndex); + var meta = LookupV2MetadataById(indexConn, hit.Value.Id); + if (meta is not null) + { + hit = (meta.Id, meta.Name, hit.Value.MatchedBy); + moniker = meta.Moniker; + + var (entries, _) = PreIndexedSource.LoadV2VersionData(_client, indexConn, source, meta.Rowid, meta.PackageHash, _appRoot); + var canonicalInstalled = MapArpVersionToCatalog(entries, installed[i].InstalledVersion) + ?? LessThanLatestArpAnchoredVersion(entries, installed[i].InstalledVersion) + ?? GreaterThanLatestArpAnchoredVersion(entries, installed[i].InstalledVersion, meta.LatestVersion); + var anchoredLatest = LatestArpAnchoredVersion(entries); + version = canonicalInstalled is not null + ? (IsGreaterThanVersionMarker(canonicalInstalled) ? null : anchoredLatest) + : meta.LatestVersion; + + if (canonicalInstalled is not null) + { + installed[i].InstalledVersion = canonicalInstalled; + installed[i].InstalledVersionCanonical = true; + } + } + } + catch { /* version data fetch is best-effort; fall back to the installed DB identity below */ } + } + + installed[i].Correlated = new SearchMatch + { + SourceName = source.Name, + SourceKind = source.Kind, + Id = hit.Value.Id, + Name = hit.Value.Name, + Moniker = moniker, + Version = version, + MatchCriteria = hit.Value.MatchedBy, + }; + } + } + + return warnings; + } + + private static bool InstalledDbIdentityTablesPresent(Microsoft.Data.Sqlite.SqliteConnection conn) => + TableExists(conn, "manifest") && + TableExists(conn, "ids") && + TableExists(conn, "names") && + ((TableExists(conn, "pfns") && TableExists(conn, "pfns_map")) || + (TableExists(conn, "productcodes") && TableExists(conn, "productcodes_map")) || + (TableExists(conn, "upgradecodes") && TableExists(conn, "upgradecodes_map"))); + + private static bool TableExists(Microsoft.Data.Sqlite.SqliteConnection conn, string name) => + QueryOptionalLong(conn, "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = @v LIMIT 1", name) is not null; + + private static (string Id, string Name, string MatchedBy)? LookupInstalledDbIdentityMatch( + Microsoft.Data.Sqlite.SqliteConnection conn, + InstalledPackage pkg) + { + foreach (var pfn in pkg.PackageFamilyNames) + { + var hit = LookupInstalledDbIdentityMatch(conn, "pfns", "pfns_map", "pfn", pfn, "PackageFamilyName"); + if (hit is not null) return hit; + } + + foreach (var code in pkg.UpgradeCodes) + { + var hit = LookupInstalledDbIdentityMatch(conn, "upgradecodes", "upgradecodes_map", "upgradecode", code, "UpgradeCode"); + if (hit is not null) return hit; + } + + foreach (var code in pkg.ProductCodes) + { + var hit = LookupInstalledDbIdentityMatch(conn, "productcodes", "productcodes_map", "productcode", code, "ProductCode"); + if (hit is not null) return hit; + } + + return null; + } + + internal static (string Id, string Name, string MatchedBy)? LookupInstalledDbIdentityMatchForTesting( + Microsoft.Data.Sqlite.SqliteConnection conn, + InstalledPackage pkg) + => LookupInstalledDbIdentityMatch(conn, pkg); + + private static (string Id, string Name, string MatchedBy)? LookupInstalledDbIdentityMatch( + Microsoft.Data.Sqlite.SqliteConnection conn, + string valueTable, + string mapTable, + string valueColumn, + string value, + string matchedBy) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = $""" + SELECT ids.id, names.name + FROM {mapTable} map + JOIN {valueTable} value ON map.{valueColumn} = value.rowid + JOIN manifest ON map.manifest = manifest.rowid + JOIN ids ON manifest.id = ids.rowid + JOIN names ON manifest.name = names.rowid + WHERE value.{valueColumn} = @v + LIMIT 1 + """; + cmd.Parameters.AddWithValue("@v", value.ToLowerInvariant()); + using var reader = cmd.ExecuteReader(); + return reader.Read() + ? (reader.GetString(0), reader.GetString(1), matchedBy) + : null; + } + /// /// Correlates installed packages against the v2 pre-indexed catalog /// using PackageFamilyName / ProductCode / UpgradeCode lookups — winget's @@ -2907,7 +3108,8 @@ private List CorrelateInstalledViaIndex(List installed using var conn2 = OpenPreindexedConnection(sourceIndex); var (entries, _) = PreIndexedSource.LoadV2VersionData(_client, conn2, source, meta.Rowid, meta.PackageHash, _appRoot); canonicalInstalled = MapArpVersionToCatalog(entries, installed[idx].InstalledVersion) - ?? LessThanLatestArpAnchoredVersion(entries, installed[idx].InstalledVersion); + ?? LessThanLatestArpAnchoredVersion(entries, installed[idx].InstalledVersion) + ?? GreaterThanLatestArpAnchoredVersion(entries, installed[idx].InstalledVersion, meta.LatestVersion); anchoredLatest = LatestArpAnchoredVersion(entries); } catch { /* version data fetch is best-effort; fall back below */ } @@ -2918,8 +3120,8 @@ private List CorrelateInstalledViaIndex(List installed // `latest_version`, which can be an internal/MSI build // number that would manufacture a phantom upgrade against // an installed canonical. - var availableVersion = (canonicalInstalled is not null && anchoredLatest is not null) - ? anchoredLatest + var availableVersion = canonicalInstalled is not null + ? (IsGreaterThanVersionMarker(canonicalInstalled) ? null : anchoredLatest) : meta.LatestVersion; installed[idx].Correlated = new SearchMatch @@ -2999,13 +3201,15 @@ private List CorrelateInstalledByNormalizedIdentity(List EnrichCorrelatedViaIndex(List installed, if (!installed[idx].InstalledVersionCanonical) { - var canonicalInstalled = MapArpVersionToCatalog(entries, installed[idx].InstalledVersion); + var canonicalInstalled = MapArpVersionToCatalog(entries, installed[idx].InstalledVersion) + ?? LessThanLatestArpAnchoredVersion(entries, installed[idx].InstalledVersion) + ?? GreaterThanLatestArpAnchoredVersion(entries, installed[idx].InstalledVersion, meta.LatestVersion); var anchoredLatest = LatestArpAnchoredVersion(entries); if (canonicalInstalled is not null) { installed[idx].InstalledVersion = canonicalInstalled; installed[idx].InstalledVersionCanonical = true; if (installed[idx].Correlated is SearchMatch correlated && anchoredLatest is not null) - installed[idx].Correlated = correlated with { Version = anchoredLatest }; + { + installed[idx].Correlated = correlated with + { + Version = IsGreaterThanVersionMarker(canonicalInstalled) ? null : anchoredLatest + }; + } } } @@ -3452,9 +3663,12 @@ private ListMatch ListMatchFromInstalled(InstalledPackage pkg) string? availableVersion = null; if (pkg.Correlated?.Version is string av) { - if (InstalledPackageHasUnknownVersion(pkg) || - RestSource.CompareVersionStrings(av, pkg.InstalledVersion) > 0) + if (!IsGreaterThanVersionMarker(pkg.InstalledVersion) && + (InstalledPackageHasUnknownVersion(pkg) || + RestSource.CompareVersionStrings(av, pkg.InstalledVersion) > 0)) + { availableVersion = av; + } } string packageId = pkg.Correlated?.Id ?? pkg.LocalId; @@ -3487,6 +3701,44 @@ private ListMatch ListMatchFromInstalled(InstalledPackage pkg) }; } + internal static List SuppressDuplicateAvailableVersions(IReadOnlyList matches) + { + var duplicateKeys = matches + .Where(match => !string.IsNullOrWhiteSpace(match.SourceName)) + .GroupBy(match => $"{match.SourceName}\0{match.Id}", StringComparer.OrdinalIgnoreCase) + .Where(group => group.Count() > 1) + .ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase); + + if (duplicateKeys.Count == 0) + return matches.ToList(); + + var keepLocalIds = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var (key, group) in duplicateKeys) + { + var bestVersion = group + .Select(match => match.InstalledVersion) + .Aggregate((best, version) => RestSource.CompareVersionStrings(version, best) > 0 ? version : best); + foreach (var match in group.Where(match => RestSource.CompareVersionStrings(match.InstalledVersion, bestVersion) == 0)) + keepLocalIds.Add($"{key}\0{match.LocalId}"); + } + + return matches + .Select(match => + { + if (match.AvailableVersion is null || string.IsNullOrWhiteSpace(match.SourceName)) + return match; + + var key = $"{match.SourceName}\0{match.Id}"; + if (!duplicateKeys.ContainsKey(key)) + return match; + + return keepLocalIds.Contains($"{key}\0{match.LocalId}") + ? match + : match with { AvailableVersion = null }; + }) + .ToList(); + } + internal static bool TryGetWinGetPackageIdentityFromLocalId( string localId, IReadOnlyList sources, @@ -3536,9 +3788,13 @@ private static bool InstalledPackageHasUnknownVersion(InstalledPackage pkg) => pkg.InstalledVersion.Equals("Unknown", StringComparison.OrdinalIgnoreCase); private static bool InstalledPackageHasUpgrade(InstalledPackage pkg) => + !IsGreaterThanVersionMarker(pkg.InstalledVersion) && pkg.Correlated?.Version is string availableVersion && RestSource.CompareVersionStrings(availableVersion, pkg.InstalledVersion) > 0; + private static bool IsGreaterThanVersionMarker(string version) => + version.TrimStart().StartsWith('>'); + private static bool InstallerMatchesRequested( Installer installer, string? requestedType, diff --git a/dotnet/src/Devolutions.Pinget.Core/SourceStore.cs b/dotnet/src/Devolutions.Pinget.Core/SourceStore.cs index b6ad3e4..baf8931 100644 --- a/dotnet/src/Devolutions.Pinget.Core/SourceStore.cs +++ b/dotnet/src/Devolutions.Pinget.Core/SourceStore.cs @@ -113,6 +113,15 @@ public static string SourceStateDir(SourceRecord source, string? appRoot = null) return Path.Combine(root, "sources", SanitizePathSegment(source.Name)); } + public static string SourceInstalledDbPath(SourceRecord source, string? appRoot = null) + { + var root = NormalizeAppRoot(appRoot); + if (UsesPackagedLayout(root)) + return Path.Combine(root, SanitizePathSegment(source.Identifier), "installed.db"); + + return Path.Combine(SourceStateDir(source, root), "installed.db"); + } + public static string PinsDbPath(string? appRoot = null) { var root = NormalizeAppRoot(appRoot); diff --git a/dotnet/src/Devolutions.Pinget.Core/SystemWingetSourceStore.cs b/dotnet/src/Devolutions.Pinget.Core/SystemWingetSourceStore.cs index 5017589..56dfaa2 100644 --- a/dotnet/src/Devolutions.Pinget.Core/SystemWingetSourceStore.cs +++ b/dotnet/src/Devolutions.Pinget.Core/SystemWingetSourceStore.cs @@ -110,7 +110,7 @@ internal static List ParseExport(string output) records.Add(source); } - return OrderSources(records); + return records; } private static WingetCommandResult RunChecked(IReadOnlyList args, string action) @@ -158,7 +158,6 @@ private static bool TryParseJsonSources(string json, out List sour { foreach (var sourceElement in root.EnumerateArray()) sources.Add(ParseSourceElement(sourceElement)); - sources = OrderSources(sources); return true; } @@ -168,7 +167,6 @@ private static bool TryParseJsonSources(string json, out List sour { foreach (var sourceElement in sourcesElement.EnumerateArray()) sources.Add(ParseSourceElement(sourceElement)); - sources = OrderSources(sources); return true; } @@ -301,13 +299,6 @@ JsonValueKind.Number when trustLevel.TryGetInt32(out var value) && value > 0 => private static bool IsTrustedValue(JsonElement value) => value.ValueKind == JsonValueKind.String && value.GetString()?.Equals("Trusted", StringComparison.OrdinalIgnoreCase) == true; - - private static List OrderSources(List sources) => - sources - .OrderBy(source => source.Explicit) - .ThenByDescending(source => source.Priority) - .ThenBy(source => source.Name, StringComparer.OrdinalIgnoreCase) - .ToList(); } internal sealed record WingetCommandResult(int ExitCode, string Stdout, string Stderr); diff --git a/rust/crates/pinget-core/src/lib.rs b/rust/crates/pinget-core/src/lib.rs index cd026fb..02ee3b0 100644 --- a/rust/crates/pinget-core/src/lib.rs +++ b/rust/crates/pinget-core/src/lib.rs @@ -1235,12 +1235,10 @@ impl Repository { bail!("list --source currently requires a query or explicit filter"); } + self.refresh_system_winget_sources()?; + let has_filter = list_query_needs_available_lookup(query); - // Plain `list` should still correlate installed packages back to their - // catalog identities so callers see canonical package ids/source names, - // matching winget and the fixed C# Pinget behavior. `upgrade_only` - // still adds the available-version specific filtering on top. - let needs_available = true; + let needs_available = has_filter || query.upgrade_only; let mut warnings = Vec::new(); if !installed_package_discovery_supported() { @@ -1248,24 +1246,13 @@ impl Repository { } let mut installed = collect_installed_packages(query.install_scope.as_deref())?; - if needs_available { - // Authoritative correlation via the v2 index's identity tables - // (PackageFamilyName / ProductCode / UpgradeCode). This is winget's - // primary path and resolves cases where display-name matching is - // ambiguous (Microsoft.Teams vs Microsoft.Teams.Free) or impossible - // (MSIX with `ms-resource:` placeholder names). - warnings.extend(self.correlate_installed_via_index(&mut installed, query.source.as_deref())?); - - // ARP entries that lack identity keys (no PFN/PC/UC) still match - // winget's ARP correlation when their (DisplayName, Publisher), - // run through the NameNormalizer, lands on a single package in - // norm_names2 ∩ norm_publishers2. Covers Inno Setup-style - // installers, MSIs whose ARP keys don't include ProductCode, and - // any vendor that publishes a DisplayName that differs from the - // catalog's PackageName but matches an AppsAndFeaturesEntries - // DisplayName. - warnings.extend(self.correlate_installed_by_normalized_identity(&mut installed, query.source.as_deref())?); - } + // Plain `list` should still correlate installed packages back to their + // catalog identities so callers see canonical package ids/source names. + // Available-version lookups and loose name-only enrichment remain gated + // by `needs_available` below. + warnings.extend(self.correlate_installed_via_installed_db(&mut installed, query.source.as_deref())?); + warnings.extend(self.correlate_installed_via_index(&mut installed, query.source.as_deref())?); + warnings.extend(self.correlate_installed_by_normalized_identity(&mut installed, query.source.as_deref())?); if needs_available && has_filter { // Filtered lookup: search sources with the user's query for any @@ -1335,9 +1322,12 @@ impl Repository { }; let mut list_matches = matches .into_iter() - .map(list_match_from_installed) + .map(|package| list_match_from_installed(package, &self.store.sources)) .filter(|item| !query.upgrade_only || query.include_pinned || !is_upgrade_blocked_by_pin(item, &pins)) .collect::>(); + if !query.upgrade_only { + list_matches = suppress_duplicate_available_versions(list_matches); + } let truncated = if let Some(limit) = query.count { let was_truncated = list_matches.len() > limit; list_matches.truncate(limit); @@ -1392,6 +1382,109 @@ impl Repository { Ok(located.warnings) } + /// Uses winget's per-source installed tracking DB when available. This is + /// an exact identity signal recorded by winget itself, and it preserves + /// source order for sources that do not expose a preindexed identity table + /// such as REST sources. + fn correlate_installed_via_installed_db( + &mut self, + installed: &mut [InstalledPackage], + requested_source: Option<&str>, + ) -> Result> { + let source_indices = self + .store + .sources + .iter() + .enumerate() + .filter(|(_, source)| { + requested_source + .map(|name| source.name.eq_ignore_ascii_case(name)) + .unwrap_or(true) + }) + .map(|(index, _)| index) + .collect::>(); + + for source_index in source_indices { + let source = self.source_clone(source_index); + let installed_db_path = source_installed_db_path(&self.app_root, &source); + if !installed_db_path.exists() { + continue; + } + + let connection = Self::open_sqlite_connection(installed_db_path)?; + if !installed_db_identity_tables_present(&connection)? { + continue; + } + + for package in installed.iter_mut() { + if package.correlated.is_some() { + continue; + } + let Some((id, name, matched_by)) = lookup_installed_db_identity_match(&connection, package)? else { + continue; + }; + let (id, name, moniker, version) = if source.kind == SourceKind::PreIndexed { + match self + .open_preindexed_connection(source_index) + .and_then(|index_connection| { + let Some(meta) = lookup_v2_metadata_by_id(&index_connection, &id)? else { + return Ok((id.clone(), name.clone(), None, None)); + }; + let (canonical_installed, anchored_latest) = + match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) { + Ok((entries, _)) => ( + map_arp_version_to_catalog(&entries, &package.installed_version) + .or_else(|| { + less_than_latest_arp_anchored_version( + &entries, + &package.installed_version, + ) + }) + .or_else(|| { + greater_than_latest_arp_anchored_version( + &entries, + &package.installed_version, + &meta.latest_version, + ) + }), + latest_arp_anchored_version(&entries), + ), + Err(_) => (None, None), + }; + let version = match (&canonical_installed, anchored_latest) { + (Some(canonical), _) if is_greater_than_version_marker(canonical) => None, + (Some(_), Some(anchored)) => Some(anchored), + (Some(_), None) => None, + _ => Some(meta.latest_version.clone()), + }; + if let Some(version) = canonical_installed { + package.installed_version = version; + package.installed_version_canonical = true; + } + Ok((meta.id, meta.name, meta.moniker, version)) + }) { + Ok(enriched) => enriched, + Err(_) => (id, name, None, None), + } + } else { + (id, name, None, None) + }; + package.correlated = Some(SearchMatch { + source_name: source.name.clone(), + source_kind: source.kind, + id, + name, + moniker, + version, + channel: None, + match_criteria: Some(matched_by.to_owned()), + }); + } + } + + Ok(Vec::new()) + } + /// Correlates installed packages against the v2 pre-indexed catalog using /// PackageFamilyName / ProductCode / UpgradeCode lookups — winget's /// authoritative correlation path. Also rewrites `installed_version` to @@ -1450,9 +1543,17 @@ impl Repository { let (canonical_installed, anchored_latest) = match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) { Ok((entries, _)) => ( - map_arp_version_to_catalog(&entries, &installed[idx].installed_version).or_else(|| { - less_than_latest_arp_anchored_version(&entries, &installed[idx].installed_version) - }), + map_arp_version_to_catalog(&entries, &installed[idx].installed_version) + .or_else(|| { + less_than_latest_arp_anchored_version(&entries, &installed[idx].installed_version) + }) + .or_else(|| { + greater_than_latest_arp_anchored_version( + &entries, + &installed[idx].installed_version, + &meta.latest_version, + ) + }), latest_arp_anchored_version(&entries), ), Err(_) => (None, None), @@ -1464,8 +1565,10 @@ impl Repository { // 1.8's `8000.836.2153.0`) that would manufacture a phantom // upgrade against an installed canonical like `1.8.6`. let available_version = match (&canonical_installed, anchored_latest) { - (Some(_), Some(anchored)) => anchored, - _ => meta.latest_version, + (Some(canonical), _) if is_greater_than_version_marker(canonical) => None, + (Some(_), Some(anchored)) => Some(anchored), + (Some(_), None) => None, + _ => Some(meta.latest_version), }; let installed_pkg = &mut installed[idx]; installed_pkg.correlated = Some(SearchMatch { @@ -1474,7 +1577,7 @@ impl Repository { id: meta.id, name: meta.name, moniker: meta.moniker, - version: Some(available_version), + version: available_version, channel: None, match_criteria: Some(by.to_owned()), }); @@ -1566,14 +1669,26 @@ impl Repository { let (canonical_installed, anchored_latest) = match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) { Ok((entries, _)) => ( - map_arp_version_to_catalog(&entries, &installed[idx].installed_version), + map_arp_version_to_catalog(&entries, &installed[idx].installed_version) + .or_else(|| { + less_than_latest_arp_anchored_version(&entries, &installed[idx].installed_version) + }) + .or_else(|| { + greater_than_latest_arp_anchored_version( + &entries, + &installed[idx].installed_version, + &meta.latest_version, + ) + }), latest_arp_anchored_version(&entries), ), Err(_) => (None, None), }; let available_version = match (&canonical_installed, anchored_latest) { - (Some(_), Some(anchored)) => anchored, - _ => meta.latest_version, + (Some(canonical), _) if is_greater_than_version_marker(canonical) => None, + (Some(_), Some(anchored)) => Some(anchored), + (Some(_), None) => None, + _ => Some(meta.latest_version), }; let installed_pkg = &mut installed[idx]; installed_pkg.correlated = Some(SearchMatch { @@ -1582,7 +1697,7 @@ impl Repository { id: meta.id, name: meta.name, moniker: meta.moniker, - version: Some(available_version), + version: available_version, channel: None, match_criteria: Some("NormalizedNameAndPublisher".to_owned()), }); @@ -1678,15 +1793,21 @@ impl Repository { if !installed[idx].installed_version_canonical { let canonical_installed = map_arp_version_to_catalog(&entries, &installed[idx].installed_version) - .or_else(|| less_than_latest_arp_anchored_version(&entries, &installed[idx].installed_version)); + .or_else(|| less_than_latest_arp_anchored_version(&entries, &installed[idx].installed_version)) + .or_else(|| { + greater_than_latest_arp_anchored_version( + &entries, + &installed[idx].installed_version, + &meta.latest_version, + ) + }); let anchored_latest = latest_arp_anchored_version(&entries); if let Some(canonical) = canonical_installed { + let greater_than_latest = is_greater_than_version_marker(&canonical); installed[idx].installed_version = canonical; installed[idx].installed_version_canonical = true; - if let (Some(correlated), Some(anchored)) = - (installed[idx].correlated.as_mut(), anchored_latest) - { - correlated.version = Some(anchored); + if let Some(correlated) = installed[idx].correlated.as_mut() { + correlated.version = if greater_than_latest { None } else { anchored_latest }; } } } @@ -3465,6 +3586,9 @@ fn allow_loose_list_correlation(query: &ListQuery) -> bool { } fn installed_package_has_upgrade(package: &InstalledPackage) -> bool { + if is_greater_than_version_marker(&package.installed_version) { + return false; + } package .correlated .as_ref() @@ -3472,6 +3596,10 @@ fn installed_package_has_upgrade(package: &InstalledPackage) -> bool { .is_some_and(|version| compare_version(version, &package.installed_version) == Ordering::Greater) } +fn is_greater_than_version_marker(version: &str) -> bool { + version.trim_start().starts_with('>') +} + fn installed_package_has_unknown_version(package: &InstalledPackage) -> bool { package.installed_version.eq_ignore_ascii_case("Unknown") } @@ -3598,11 +3726,12 @@ fn version_matches_pin_pattern(version: &str, pattern: &str) -> bool { version.eq_ignore_ascii_case(pattern) } -fn list_match_from_installed(package: InstalledPackage) -> ListMatch { +fn list_match_from_installed(package: InstalledPackage, sources: &[SourceRecord]) -> ListMatch { let available_version = package.correlated.as_ref().and_then(|candidate| { candidate.version.as_ref().and_then(|candidate_version| { - if installed_package_has_unknown_version(&package) - || compare_version(candidate_version, &package.installed_version) == Ordering::Greater + if !is_greater_than_version_marker(&package.installed_version) + && (installed_package_has_unknown_version(&package) + || compare_version(candidate_version, &package.installed_version) == Ordering::Greater) { Some(candidate_version.clone()) } else { @@ -3610,17 +3739,25 @@ fn list_match_from_installed(package: InstalledPackage) -> ListMatch { } }) }); - let source_name = package + let mut source_name = package .correlated .as_ref() .map(|candidate| candidate.source_name.clone()) .filter(|value| !value.is_empty()); - let id = package + let mut id = package .correlated .as_ref() .map(|candidate| candidate.id.clone()) .unwrap_or_else(|| package.local_id.clone()); + if source_name.is_none() + && let Some((local_package_id, local_source_name)) = + winget_package_identity_from_local_id(&package.local_id, sources) + { + id = local_package_id; + source_name = Some(local_source_name); + } + ListMatch { name: package.name, id, @@ -3638,6 +3775,79 @@ fn list_match_from_installed(package: InstalledPackage) -> ListMatch { } } +fn suppress_duplicate_available_versions(matches: Vec) -> Vec { + let mut grouped: HashMap<(String, String), Vec> = HashMap::new(); + for (index, item) in matches.iter().enumerate() { + let Some(source_name) = item.source_name.as_ref().filter(|value| !value.trim().is_empty()) else { + continue; + }; + grouped + .entry((source_name.to_ascii_lowercase(), item.id.to_ascii_lowercase())) + .or_default() + .push(index); + } + + let mut keep = BTreeSet::new(); + for indexes in grouped.values().filter(|indexes| indexes.len() > 1) { + let Some(best) = indexes + .iter() + .map(|index| matches[*index].installed_version.as_str()) + .max_by(|left, right| compare_version(left, right)) + else { + continue; + }; + for index in indexes { + if compare_version(&matches[*index].installed_version, best) == Ordering::Equal { + keep.insert(*index); + } + } + } + + matches + .into_iter() + .enumerate() + .map(|(index, mut item)| { + if item.available_version.is_some() && !keep.contains(&index) { + let is_duplicate = item + .source_name + .as_ref() + .map(|source_name| { + grouped + .get(&(source_name.to_ascii_lowercase(), item.id.to_ascii_lowercase())) + .is_some_and(|indexes| indexes.len() > 1) + }) + .unwrap_or(false); + if is_duplicate { + item.available_version = None; + } + } + item + }) + .collect() +} + +fn winget_package_identity_from_local_id(local_id: &str, sources: &[SourceRecord]) -> Option<(String, String)> { + let package_id_start_index = local_id.rfind('\\').map_or(0, |index| index + 1); + for source in sources { + let source_suffix = format!("_{}", source.identifier); + let Some(source_identifier_start_index) = local_id.len().checked_sub(source_suffix.len()) else { + continue; + }; + if !local_id[source_identifier_start_index..].eq_ignore_ascii_case(&source_suffix) { + continue; + } + if source_identifier_start_index <= package_id_start_index { + return None; + } + let package_id = local_id[package_id_start_index..source_identifier_start_index].to_owned(); + if package_id.trim().is_empty() { + return None; + } + return Some((package_id, source.name.clone())); + } + None +} + fn list_sort_weight(package: &InstalledPackage) -> usize { if package.local_id.starts_with("ARP\\") { 0 @@ -3893,6 +4103,89 @@ fn lookup_identity_match_v2( Ok(None) } +fn installed_db_identity_tables_present(connection: &Connection) -> Result { + Ok(table_exists(connection, "manifest")? + && table_exists(connection, "ids")? + && table_exists(connection, "names")? + && ((table_exists(connection, "pfns")? && table_exists(connection, "pfns_map")?) + || (table_exists(connection, "productcodes")? && table_exists(connection, "productcodes_map")?) + || (table_exists(connection, "upgradecodes")? && table_exists(connection, "upgradecodes_map")?))) +} + +fn table_exists(connection: &Connection, name: &str) -> Result { + query_optional_value( + connection, + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1 LIMIT 1", + vec![SqlValue::Text(name.to_owned())], + |row| row_i64(row, 0), + ) + .map(|value| value.is_some()) +} + +fn lookup_installed_db_identity_match( + connection: &Connection, + package: &InstalledPackage, +) -> Result> { + for pfn in &package.package_family_names { + if let Some(hit) = + lookup_installed_db_identity_match_value(connection, "pfns", "pfns_map", "pfn", pfn, "PackageFamilyName")? + { + return Ok(Some(hit)); + } + } + for code in &package.upgrade_codes { + if let Some(hit) = lookup_installed_db_identity_match_value( + connection, + "upgradecodes", + "upgradecodes_map", + "upgradecode", + code, + "UpgradeCode", + )? { + return Ok(Some(hit)); + } + } + for code in &package.product_codes { + if let Some(hit) = lookup_installed_db_identity_match_value( + connection, + "productcodes", + "productcodes_map", + "productcode", + code, + "ProductCode", + )? { + return Ok(Some(hit)); + } + } + Ok(None) +} + +fn lookup_installed_db_identity_match_value( + connection: &Connection, + value_table: &'static str, + map_table: &'static str, + value_column: &'static str, + value: &str, + matched_by: &'static str, +) -> Result> { + let sql = format!( + "SELECT ids.id, names.name \ + FROM {map_table} map \ + JOIN {value_table} value ON map.{value_column} = value.rowid \ + JOIN manifest ON map.manifest = manifest.rowid \ + JOIN ids ON manifest.id = ids.rowid \ + JOIN names ON manifest.name = names.rowid \ + WHERE value.{value_column} = ?1 \ + LIMIT 1" + ); + query_optional_value( + connection, + &sql, + vec![SqlValue::Text(value.to_ascii_lowercase())], + |row| Ok((row_string(row, 0)?, row_string(row, 1)?, matched_by)), + ) +} + /// Resolves a v2 package by its catalog id back to its rowid + hash so the /// post-correlation enrichment pass can load versionData for rows that /// correlated through the name-based fallback (which throws the rowid away). @@ -4060,6 +4353,57 @@ fn less_than_latest_arp_anchored_version(entries: &[PackageVersionDataEntry], ar Some(format!("< {}", latest.version)) } +/// Returns `> latest` when ARP range metadata proves the installed package +/// version is newer than the newest catalog version that has ARP bounds. This +/// happens for inbox MSIX frameworks that Windows services beyond winget's +/// latest user-facing catalog mapping. +fn greater_than_latest_arp_anchored_version( + entries: &[PackageVersionDataEntry], + arp_version: &str, + catalog_latest_version: &str, +) -> Option { + if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") { + return None; + } + + let latest = entries + .iter() + .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some()) + .max_by(|a, b| compare_version(&a.version, &b.version))?; + let latest_max = latest.arp_max_version.as_deref()?; + if !latest.version.eq_ignore_ascii_case(catalog_latest_version) { + return None; + } + if !has_different_leading_version_number(latest_max, &latest.version) { + return None; + } + if compare_version(arp_version, latest_max) != Ordering::Greater { + return None; + } + + Some(format!("> {}", latest.version)) +} + +fn has_different_leading_version_number(left: &str, right: &str) -> bool { + let Some(left) = leading_version_number(left) else { + return false; + }; + let Some(right) = leading_version_number(right) else { + return false; + }; + !left.eq_ignore_ascii_case(right) +} + +fn leading_version_number(value: &str) -> Option<&str> { + let trimmed = value.trim_start(); + let length = trimmed + .chars() + .take_while(|ch| ch.is_ascii_digit()) + .map(char::len_utf8) + .sum(); + (length > 0).then_some(&trimmed[..length]) +} + /// Returns the latest catalog Version that carries ARP-range metadata /// (`aMiV` / `aMaV`). Packages like `Microsoft.WindowsAppRuntime.1.8` also /// publish "internal" version rows whose `v` is an MSI build number @@ -4355,9 +4699,6 @@ fn collect_appmodel_packages( Err(_) => continue, }; - let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else { - continue; - }; let install_location = read_reg_string(&subkey, "PackageRootFolder"); if install_location.as_deref().is_some_and(is_windows_system_path) { continue; @@ -4366,6 +4707,16 @@ fn collect_appmodel_packages( let Some(metadata) = parse_msix_package_full_name(&key_name) else { continue; }; + if is_msix_split_resource_package(&metadata.resource_id) { + continue; + } + + let Some(name) = read_reg_string(&subkey, "DisplayName") + .or_else(|| read_appmodel_display_name(&subkey)) + .filter(|value| !value.is_empty()) + else { + continue; + }; let local_id = format!(r"MSIX\{key_name}"); let dedupe_key = format!( @@ -4402,6 +4753,7 @@ fn collect_appmodel_packages( #[cfg(windows)] struct ParsedMsixPackageFullName { version: String, + resource_id: String, family_name: String, } @@ -4432,10 +4784,22 @@ fn parse_msix_package_full_name(value: &str) -> Option Option { + let app_key = package_key.open_subkey(r"App\Capabilities").ok()?; + read_reg_string(&app_key, "ApplicationDescription").or_else(|| read_reg_string(&app_key, "ApplicationName")) +} + +#[cfg(windows)] +fn is_msix_split_resource_package(resource_id: &str) -> bool { + resource_id.trim().to_ascii_lowercase().starts_with("split.") +} + #[cfg(windows)] fn is_windows_system_path(path: &str) -> bool { path.trim().to_ascii_lowercase().starts_with(r"c:\windows\") @@ -4531,6 +4895,16 @@ fn source_state_dir(app_root: &Path, source: &SourceRecord) -> PathBuf { app_root.join("sources").join(sanitize_path_segment(&source.name)) } +fn source_installed_db_path(app_root: &Path, source: &SourceRecord) -> PathBuf { + if uses_packaged_layout(app_root) { + return app_root + .join(sanitize_path_segment(&source.identifier)) + .join("installed.db"); + } + + source_state_dir(app_root, source).join("installed.db") +} + fn preindexed_package_path(app_root: &Path, source: &SourceRecord) -> PathBuf { source_state_dir(app_root, source).join("source.msix") } @@ -4950,7 +5324,7 @@ fn parse_system_winget_source_export(output: &str) -> Result> if let Ok(value) = serde_json::from_str::(trimmed) && let Some(sources) = parse_system_winget_source_export_value(&value)? { - return Ok(order_sources(sources)); + return Ok(sources); } let mut sources = Vec::new(); @@ -4967,7 +5341,7 @@ fn parse_system_winget_source_export(output: &str) -> Result> sources.append(&mut parsed); } - Ok(order_sources(sources)) + Ok(sources) } fn parse_system_winget_source_export_value(value: &JsonValue) -> Result>> { @@ -5078,16 +5452,6 @@ fn json_trust_value_is_trusted(value: &JsonValue) -> bool { .is_some_and(|value| value.eq_ignore_ascii_case("Trusted")) } -fn order_sources(mut sources: Vec) -> Vec { - sources.sort_by(|left, right| { - left.explicit - .cmp(&right.explicit) - .then_with(|| right.priority.cmp(&left.priority)) - .then_with(|| left.name.to_ascii_lowercase().cmp(&right.name.to_ascii_lowercase())) - }); - sources -} - fn parse_packaged_source_store(user_sources_yaml: Option<&str>, metadata_yaml: Option<&str>) -> Option { if user_sources_yaml.is_none() && metadata_yaml.is_none() { return None; @@ -10469,6 +10833,11 @@ mod tests { .join("Packages") .join(PACKAGED_FAMILY_NAME) .join("LocalState"); + let source = SourceStore::default() + .sources + .into_iter() + .find(|source| source.name == "winget") + .expect("winget source"); assert!(uses_packaged_layout(&app_root)); assert!( user_settings_path(&app_root) @@ -10485,6 +10854,24 @@ mod tests { PACKAGED_FAMILY_NAME )) ); + assert!( + source_state_dir(&app_root, &source) + .display() + .to_string() + .ends_with(&format!( + r"Packages\{}\LocalState\Microsoft.PreIndexed.Package\Microsoft.Winget.Source_8wekyb3d8bbwe", + PACKAGED_FAMILY_NAME + )) + ); + assert!( + source_installed_db_path(&app_root, &source) + .display() + .to_string() + .ends_with(&format!( + r"Packages\{}\LocalState\Microsoft.Winget.Source_8wekyb3d8bbwe\installed.db", + PACKAGED_FAMILY_NAME + )) + ); } #[cfg(windows)] @@ -10543,24 +10930,26 @@ mod tests { #[test] fn system_winget_export_parses_json_lines() { - let output = r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"} -{"Arg":"https://cdn.contoso.test/cache","Data":"Contoso.Source_8wekyb3d8bbwe","Explicit":true,"Identifier":"Contoso.Source_8wekyb3d8bbwe","Name":"contoso-cache","TrustLevel":["Trusted","StoreOrigin"],"Type":"Microsoft.PreIndexed.Package"} + let output = r#"{"Arg":"https://api.winget.pro/feed","Data":"","Explicit":false,"Identifier":"api.winget.pro","Name":"winget.pro","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"} +{"Arg":"https://storeedgefd.dsx.mp.microsoft.com/v9.0","Data":"","Explicit":false,"Identifier":"StoreEdgeFD","Name":"msstore","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"} +{"Arg":"https://cdn.winget.microsoft.com/cache","Data":"Microsoft.Winget.Source_8wekyb3d8bbwe","Explicit":false,"Identifier":"Microsoft.Winget.Source_8wekyb3d8bbwe","Name":"winget","TrustLevel":["Trusted","StoreOrigin"],"Type":"Microsoft.PreIndexed.Package"} +{"Arg":"https://cdn.winget.microsoft.com/fonts","Data":"Microsoft.Winget.Fonts.Source_8wekyb3d8bbwe","Explicit":true,"Identifier":"Microsoft.Winget.Fonts.Source_8wekyb3d8bbwe","Name":"winget-font","TrustLevel":["Trusted","StoreOrigin"],"Type":"Microsoft.PreIndexed.Package"} "#; let sources = parse_system_winget_source_export(output).expect("source export"); assert_eq!( sources.iter().map(|source| source.name.as_str()).collect::>(), - vec!["contoso", "contoso-cache"] + vec!["winget.pro", "msstore", "winget", "winget-font"] ); assert_eq!(sources[0].kind, SourceKind::Rest); - assert_eq!(sources[0].arg, "https://api.contoso.test/feed"); - assert_eq!(sources[0].identifier, "api.contoso.test"); + assert_eq!(sources[0].arg, "https://api.winget.pro/feed"); + assert_eq!(sources[0].identifier, "api.winget.pro"); assert_eq!(sources[0].trust_level, "Trusted"); assert!(!sources[0].explicit); - assert_eq!(sources[1].kind, SourceKind::PreIndexed); - assert_eq!(sources[1].identifier, "Contoso.Source_8wekyb3d8bbwe"); - assert!(sources[1].explicit); + assert_eq!(sources[2].kind, SourceKind::PreIndexed); + assert_eq!(sources[2].identifier, "Microsoft.Winget.Source_8wekyb3d8bbwe"); + assert!(!sources[2].explicit); } #[test] @@ -11384,6 +11773,49 @@ Installers: assert_eq!(rowid, 100); } + #[test] + fn lookup_installed_db_identity_match_returns_product_code_match() { + let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db"); + execute_batch_sql( + &connection, + "CREATE TABLE ids (rowid INTEGER PRIMARY KEY, id TEXT NOT NULL);\n\ + CREATE TABLE names (rowid INTEGER PRIMARY KEY, name TEXT NOT NULL);\n\ + CREATE TABLE manifest (rowid INTEGER PRIMARY KEY, id INT64 NOT NULL, name INT64 NOT NULL);\n\ + CREATE TABLE productcodes (rowid INTEGER PRIMARY KEY, productcode TEXT NOT NULL);\n\ + CREATE TABLE productcodes_map (manifest INT64 NOT NULL, productcode INT64 NOT NULL);\n\ + INSERT INTO ids VALUES (1, 'Git.Git');\n\ + INSERT INTO names VALUES (1, 'Git');\n\ + INSERT INTO manifest VALUES (10, 1, 1);\n\ + INSERT INTO productcodes VALUES (20, 'git_is1');\n\ + INSERT INTO productcodes_map VALUES (10, 20);", + ) + .expect("seed schema"); + let package = InstalledPackage { + name: "Git".to_owned(), + local_id: r"ARP\Machine\X64\Git_is1".to_owned(), + installed_version: "2.54.0".to_owned(), + publisher: None, + scope: None, + installer_category: None, + install_location: None, + package_family_names: Vec::new(), + product_codes: vec!["git_is1".to_owned()], + upgrade_codes: Vec::new(), + correlated: None, + installed_version_canonical: false, + correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, + }; + + let (id, name, matched_by) = lookup_installed_db_identity_match(&connection, &package) + .expect("query") + .expect("product code match"); + + assert_eq!(id, "Git.Git"); + assert_eq!(name, "Git"); + assert_eq!(matched_by, "ProductCode"); + } + #[test] fn lookup_unique_normalized_identity_rejects_ambiguous_match() { // Two distinct packages share the same (norm_name, norm_publisher) @@ -13009,7 +13441,7 @@ Installers: assert!(installed_package_matches_upgrade_filter(&package, &query)); assert_eq!( - list_match_from_installed(package).available_version.as_deref(), + list_match_from_installed(package, &[]).available_version.as_deref(), Some("2.0.0") ); } @@ -13266,7 +13698,26 @@ Installers: .expect("package metadata"); assert_eq!(parsed.version, "0.98.1.0"); + assert_eq!(parsed.resource_id, ""); assert_eq!(parsed.family_name, "Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe"); + assert!(!is_msix_split_resource_package(&parsed.resource_id)); + } + + #[cfg(windows)] + #[test] + fn classifies_msix_split_resource_package() { + let parsed = parse_msix_package_full_name( + "Microsoft.XboxSpeechToTextOverlay_1.97.17002.0_neutral_split.scale-125_8wekyb3d8bbwe", + ) + .expect("package metadata"); + + assert_eq!(parsed.version, "1.97.17002.0"); + assert_eq!(parsed.resource_id, "split.scale-125"); + assert_eq!( + parsed.family_name, + "Microsoft.XboxSpeechToTextOverlay_split.scale-125_8wekyb3d8bbwe" + ); + assert!(is_msix_split_resource_package(&parsed.resource_id)); } #[cfg(windows)] @@ -14257,6 +14708,30 @@ Installers: assert!(less_than_latest_arp_anchored_version(&entries, "25.4.0").is_none()); } + #[test] + fn map_arp_version_reports_greater_than_latest_for_serviced_msix_framework_version() { + // WindowsAppRuntime packages can be serviced beyond the latest winget + // catalog versionData range. winget renders those as `> latest` and + // does not report an available downgrade. + let entries = vec![ + version_entry("1.5.8", Some("5001.373.1736.0"), Some("5001.373.1736.0")), + version_entry("1.5.7", Some("5001.337.1906.0"), Some("5001.337.1906.0")), + ]; + + assert!(map_arp_version_to_catalog(&entries, "5001.400.42.0").is_none()); + assert!(less_than_latest_arp_anchored_version(&entries, "5001.400.42.0").is_none()); + assert_eq!( + greater_than_latest_arp_anchored_version(&entries, "5001.400.42.0", "1.5.8").as_deref(), + Some("> 1.5.8") + ); + assert!(greater_than_latest_arp_anchored_version(&entries, "5001.337.1906.0", "1.5.8").is_none()); + + let stale_normal_app_entries = vec![version_entry("2024.1.1", Some("2024.1.1.0"), Some("2024.1.1.0"))]; + assert!( + greater_than_latest_arp_anchored_version(&stale_normal_app_entries, "2026.1.3.0", "2026.2.0.0").is_none() + ); + } + #[test] fn map_arp_version_skips_entries_missing_arp_bounds() { // Older catalog packages predate AppsAndFeaturesEntries and have no @@ -14363,7 +14838,7 @@ Installers: correlated_lacks_compatible_installer: false, }; - let item = list_match_from_installed(package); + let item = list_match_from_installed(package, &[]); assert_eq!(item.id, "Microsoft.Azure.AZCopy.10"); assert_eq!(item.local_id, r"ARP\Machine\X64\AzCopy"); @@ -14371,6 +14846,63 @@ Installers: assert_eq!(item.available_version.as_deref(), Some("10.32.3")); } + #[test] + fn suppress_duplicate_available_versions_keeps_available_only_on_newest_installed_row() { + let rows = vec![ + ListMatch { + name: "Example 1.0".to_owned(), + id: "Example.Tool".to_owned(), + local_id: r"ARP\Machine\X64\old".to_owned(), + installed_version: "1.0.0".to_owned(), + available_version: Some("3.0.0".to_owned()), + source_name: Some("winget".to_owned()), + publisher: None, + scope: None, + installer_category: None, + install_location: None, + package_family_names: Vec::new(), + product_codes: Vec::new(), + upgrade_codes: Vec::new(), + }, + ListMatch { + name: "Example 2.0".to_owned(), + id: "Example.Tool".to_owned(), + local_id: r"ARP\Machine\X64\new".to_owned(), + installed_version: "2.0.0".to_owned(), + available_version: Some("3.0.0".to_owned()), + source_name: Some("winget".to_owned()), + publisher: None, + scope: None, + installer_category: None, + install_location: None, + package_family_names: Vec::new(), + product_codes: Vec::new(), + upgrade_codes: Vec::new(), + }, + ListMatch { + name: "Other".to_owned(), + id: "Other.Tool".to_owned(), + local_id: r"ARP\Machine\X64\other".to_owned(), + installed_version: "1.0.0".to_owned(), + available_version: Some("2.0.0".to_owned()), + source_name: Some("winget".to_owned()), + publisher: None, + scope: None, + installer_category: None, + install_location: None, + package_family_names: Vec::new(), + product_codes: Vec::new(), + upgrade_codes: Vec::new(), + }, + ]; + + let result = suppress_duplicate_available_versions(rows); + + assert_eq!(result[0].available_version, None); + assert_eq!(result[1].available_version.as_deref(), Some("3.0.0")); + assert_eq!(result[2].available_version.as_deref(), Some("2.0.0")); + } + #[test] fn plain_list_keeps_canonical_id_distinct_from_local_id_for_correlated_rows() { let package = InstalledPackage { @@ -14399,7 +14931,7 @@ Installers: correlated_lacks_compatible_installer: false, }; - let item = list_match_from_installed(package); + let item = list_match_from_installed(package, &[]); assert_eq!(item.id, "Atlassian.AtlassianCLI"); assert_eq!( @@ -14410,6 +14942,101 @@ Installers: assert_eq!(item.source_name.as_deref(), Some("winget")); } + #[test] + fn list_match_from_installed_derives_winget_portable_identity_from_local_id() { + let package = InstalledPackage { + name: "FFmpeg".to_owned(), + local_id: r"ARP\User\X64\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(), + installed_version: "8.0".to_owned(), + publisher: Some("Gyan".to_owned()), + scope: Some("User".to_owned()), + installer_category: Some("portable".to_owned()), + install_location: None, + package_family_names: Vec::new(), + product_codes: Vec::new(), + upgrade_codes: Vec::new(), + correlated: None, + installed_version_canonical: false, + correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, + }; + let sources = vec![SourceRecord { + name: "winget".to_owned(), + kind: SourceKind::PreIndexed, + arg: "https://cdn.winget.microsoft.com/cache".to_owned(), + identifier: "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(), + trust_level: "Trusted".to_owned(), + explicit: false, + priority: 0, + last_update: None, + source_version: None, + etag: None, + last_modified: None, + }]; + + let item = list_match_from_installed(package, &sources); + + assert_eq!(item.id, "Gyan.FFmpeg"); + assert_eq!( + item.local_id, + r"ARP\User\X64\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe" + ); + assert_eq!(item.source_name.as_deref(), Some("winget")); + } + + #[test] + fn list_match_from_installed_keeps_scanning_portable_source_identifiers() { + let package = InstalledPackage { + name: "Example".to_owned(), + local_id: r"ARP\User\X64\Vendor.Example_short".to_owned(), + installed_version: "1.0".to_owned(), + publisher: Some("Vendor".to_owned()), + scope: Some("User".to_owned()), + installer_category: Some("portable".to_owned()), + install_location: None, + package_family_names: Vec::new(), + product_codes: Vec::new(), + upgrade_codes: Vec::new(), + correlated: None, + installed_version_canonical: false, + correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, + }; + let sources = vec![ + SourceRecord { + name: "long".to_owned(), + kind: SourceKind::PreIndexed, + arg: "https://example.invalid/long".to_owned(), + identifier: "identifier-longer-than-local-id".to_owned(), + trust_level: "Trusted".to_owned(), + explicit: false, + priority: 0, + last_update: None, + source_version: None, + etag: None, + last_modified: None, + }, + SourceRecord { + name: "short".to_owned(), + kind: SourceKind::PreIndexed, + arg: "https://example.invalid/short".to_owned(), + identifier: "short".to_owned(), + trust_level: "Trusted".to_owned(), + explicit: false, + priority: 1, + last_update: None, + source_version: None, + etag: None, + last_modified: None, + }, + ]; + + let item = list_match_from_installed(package, &sources); + + assert_eq!(item.id, "Vendor.Example"); + assert_eq!(item.source_name.as_deref(), Some("short")); + } + #[test] fn list_response_serialization_keeps_pascal_case_fields() { let response = ListResponse { diff --git a/scripts/compare-list.ps1 b/scripts/compare-list.ps1 new file mode 100644 index 0000000..1ad2cd7 --- /dev/null +++ b/scripts/compare-list.ps1 @@ -0,0 +1,977 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Compares `winget list` vs Rust and C# `pinget list` metadata on the current machine. + +.DESCRIPTION + Runs `winget list` (text, parsed), C# `pinget list --output json`, and + Rust `pinget list --output json`, normalises them into a common schema, + and reports structured diffs across five classes for each implementation: + + 1. CorrelationMiss — winget shows a catalog Id + source; pinget shows + a raw ARP/MSIX local Id for the same package (matched by display name). + 2. VersionMismatch — same effective Id, different InstalledVersion. + 3. SourceMismatch — same effective Id but pinget reports a different + source name (or none) compared with winget. + 4. AvailableDelta — one tool surfaces an upgrade, the other does not. + 5. DuplicateId — pinget emits the same catalog or local Id more than + once (raw + correlated row for the same install). + Plus counts of: rows matching fully, rows only in winget, rows only + in pinget. + + The script is strictly read-only: it never mutates installs, sources, + or pins. Designed to be re-run on different machines to build a corpus + of correlation classes that pinget gets wrong. + +.PARAMETER Pinget + Backward-compatible alias for -PingetCs. + +.PARAMETER PingetCs + Path to the C# pinget executable. Defaults to the Debug build output from this repo. + +.PARAMETER PingetRust + Path to the Rust pinget executable. Defaults to rust\target\debug\pinget.exe. + +.PARAMETER Winget + Path to the winget executable. Defaults to `winget` on PATH. + +.PARAMETER FixturePath + If set, writes a JSON fixture with the full diff and raw captures. + +.PARAMETER UseSystemWingetAppRoot + Point Pinget at Desktop App Installer's LocalState through PINGET_APPROOT + so Pinget uses the same system WinGet source/pin/settings state as winget.exe. + +.PARAMETER PingetAppRoot + Explicit app root to expose to both Pinget CLIs through PINGET_APPROOT. + +.PARAMETER IncludeUnknown + Kept for upgrade parity with sibling scripts. Plain list does not pass + `--include-unknown` because both winget and pinget reject it unless + `--upgrade-available` is also present. + +.PARAMETER FailOnDiff + Exit non-zero when any non-cosmetic difference is found. + +.PARAMETER UpdateSources + Run `source update` before diffing. + +.EXAMPLE + .\compare-list.ps1 + # Quick interactive check. + +.EXAMPLE + .\compare-list.ps1 -FixturePath ./list-diff.json -FailOnDiff +#> +param( + [string]$Pinget, + [string]$PingetCs = (Join-Path $PSScriptRoot "..\dotnet\src\Devolutions.Pinget.Cli\bin\Debug\net10.0\pinget.exe"), + [string]$PingetRust = (Join-Path $PSScriptRoot "..\rust\target\debug\pinget.exe"), + [string]$Winget = "winget", + [string]$FixturePath, + [switch]$UseSystemWingetAppRoot, + [string]$PingetAppRoot, + [bool]$IncludeUnknown = $true, + [switch]$FailOnDiff, + [switch]$UpdateSources +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" +[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) +$OutputEncoding = [Console]::OutputEncoding + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +function Write-Section { + param([string]$Title) + Write-Host "" + Write-Host ("=" * 80) + Write-Host $Title + Write-Host ("=" * 80) +} + +function Invoke-CaptureLines { + param( + [Parameter(Mandatory = $true)] [string]$Executable, + [Parameter(Mandatory = $true)] [string[]]$Arguments + ) + try { + $lines = & $Executable @Arguments 2>&1 | ForEach-Object { $_.ToString() } + $exit = $LASTEXITCODE + [pscustomobject]@{ ExitCode = $exit; Lines = @($lines) } + } catch { + [pscustomobject]@{ ExitCode = -1; Lines = @($_.Exception.Message) } + } +} + +# --------------------------------------------------------------------------- +# Winget list capture + parser +# --------------------------------------------------------------------------- + +function Get-WingetListRows { + param( + [Parameter(Mandatory = $true)] [string]$Executable, + [bool]$IncludeUnknown + ) + + # Note: winget list --include-unknown is only valid with --upgrade-available + # on this winget build (v1.29.x-preview). Plain list already shows all packages. + $listArgs = @("list", "--accept-source-agreements", "--disable-interactivity") + $captured = Invoke-CaptureLines -Executable $Executable -Arguments $listArgs + + # Locate the separator line (a run of dashes) and derive column offsets + # from the header line immediately above it. winget's list table has + # either 4 or 5 columns (Available is added when any row has an upgrade). + # Name Id Version [Available] Source + $separatorIdx = -1 + for ($i = 0; $i -lt $captured.Lines.Count; $i++) { + if ($captured.Lines[$i] -match '^-{10,}$') { + $separatorIdx = $i + break + } + } + + if ($separatorIdx -lt 1) { + $emptyOk = @($captured.Lines | Where-Object { + $_ -match 'No installed package' + }).Count -gt 0 + return @{ + Rows = @() + ExitCode = $captured.ExitCode + Raw = $captured.Lines + Diagnostic = if ($emptyOk) { $null } else { + "no table header/separator found in winget list output" + } + } + } + + $header = $captured.Lines[$separatorIdx - 1] + # Column tokens in order; Available is optional. + $columnDefs = @( + @{ Key = 'Name'; Token = 'Name' }, + @{ Key = 'Id'; Token = 'Id' }, + @{ Key = 'Version'; Token = 'Version' }, + @{ Key = 'Available'; Token = 'Available' }, + @{ Key = 'Source'; Token = 'Source' } + ) + $offsets = [ordered]@{} + foreach ($col in $columnDefs) { + $pos = $header.IndexOf($col.Token) + if ($pos -ge 0) { $offsets[$col.Key] = $pos } + } + # Name and Id are required; warn if absent. + if (-not $offsets.Contains('Name') -or -not $offsets.Contains('Id')) { + return @{ + Rows = @() + ExitCode = $captured.ExitCode + Raw = $captured.Lines + Diagnostic = "winget list header is missing Name or Id column" + } + } + + function Get-Slice { + param([string]$Line, [int]$Start, [int]$End) + if ($Start -ge $Line.Length) { return "" } + $eff = [Math]::Min($End, $Line.Length) + return $Line.Substring($Start, $eff - $Start).TrimEnd().TrimStart() + } + + $orderedKeys = @($offsets.Keys) + $rows = New-Object System.Collections.Generic.List[object] + + for ($i = $separatorIdx + 1; $i -lt $captured.Lines.Count; $i++) { + $line = $captured.Lines[$i] + if ([string]::IsNullOrWhiteSpace($line)) { continue } + if ($line -match '^\d+ packages? (listed|found)') { break } + if ($line -match '^&1 | Select-Object -First 1).Trim() } catch { "unavailable: $($_.Exception.Message)" } +} + +function Get-SystemWingetAppRoot { + if (-not $IsWindows) { + throw "-UseSystemWingetAppRoot requires Windows." + } + + return Join-Path $env:LOCALAPPDATA "Packages\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\LocalState" +} + +function Get-MachineSnapshot { + param([string]$PingetCs, [string]$PingetRust, [string]$Winget, [string]$PingetAppRoot) + [pscustomobject]@{ + CapturedAt = (Get-Date).ToUniversalTime().ToString("o") + OSVersion = [System.Environment]::OSVersion.VersionString + OSArchitecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + WingetVersion = Get-ToolVersion $Winget + PingetCsVersion = Get-ToolVersion $PingetCs + PingetRustVersion = Get-ToolVersion $PingetRust + PingetAppRoot = $PingetAppRoot + } +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +if (-not [string]::IsNullOrWhiteSpace($Pinget)) { + $PingetCs = $Pinget +} + +if ($UseSystemWingetAppRoot -and -not [string]::IsNullOrWhiteSpace($PingetAppRoot)) { + throw "Use either -UseSystemWingetAppRoot or -PingetAppRoot, not both." +} + +if ($UseSystemWingetAppRoot) { + $PingetAppRoot = Get-SystemWingetAppRoot +} + +if (-not [string]::IsNullOrWhiteSpace($PingetAppRoot)) { + $env:PINGET_APPROOT = $PingetAppRoot +} + +$machine = Get-MachineSnapshot -PingetCs $PingetCs -PingetRust $PingetRust -Winget $Winget -PingetAppRoot $env:PINGET_APPROOT +Write-Section "Machine" +$machine | Format-List | Out-String | Write-Host + +if ($UpdateSources) { + Write-Host "Updating sources..." + & $Winget source update --accept-source-agreements --disable-interactivity 2>&1 | Out-Null + & $PingetCs source update 2>&1 | Out-Null + & $PingetRust source update 2>&1 | Out-Null +} + +Write-Section "Capturing winget list" +$wingetResult = Get-WingetListRows -Executable $Winget -IncludeUnknown:$IncludeUnknown +Write-Host ("winget exit={0}, parsed {1} row(s)" -f $wingetResult.ExitCode, $wingetResult.Rows.Count) +if ($wingetResult.Diagnostic) { Write-Warning $wingetResult.Diagnostic } + +Write-Section "Capturing C# pinget list" +$csResult = Get-PingetListRows -Executable $PingetCs -IncludeUnknown:$IncludeUnknown +Write-Host ("C# pinget exit={0}, parsed {1} row(s)" -f $csResult.ExitCode, $csResult.Rows.Count) +if ($csResult.Diagnostic) { Write-Warning $csResult.Diagnostic } + +Write-Section "Capturing Rust pinget list" +$rustResult = Get-PingetListRows -Executable $PingetRust -IncludeUnknown:$IncludeUnknown +Write-Host ("Rust pinget exit={0}, parsed {1} row(s)" -f $rustResult.ExitCode, $rustResult.Rows.Count) +if ($rustResult.Diagnostic) { Write-Warning $rustResult.Diagnostic } + +$csDiff = Compare-ListRows -WingetRows $wingetResult.Rows -PingetRows $csResult.Rows +$rustDiff = Compare-ListRows -WingetRows $wingetResult.Rows -PingetRows $rustResult.Rows +$implementationDiff = Compare-PingetImplementations -LeftLabel "Rust" -LeftRows $rustResult.Rows -RightLabel "CSharp" -RightRows $csResult.Rows + +Write-WingetParityReport -Label "CSharp" -Diff $csDiff +Write-WingetParityReport -Label "Rust" -Diff $rustDiff +Write-ImplementationDriftReport -Diff $implementationDiff + +$csFailCount = Get-WingetParityFailureCount $csDiff +$rustFailCount = Get-WingetParityFailureCount $rustDiff +$implementationFailCount = Get-ImplementationDriftFailureCount $implementationDiff +$failCount = $csFailCount + $rustFailCount + $implementationFailCount +$parserBroken = [bool]$wingetResult.Diagnostic -or [bool]$csResult.Diagnostic -or [bool]$rustResult.Diagnostic +$verdict = if ($failCount -eq 0 -and -not $parserBroken) { "PASS" } else { "FAIL" } +Write-Section "Result: $verdict ($failCount non-cosmetic differences)" +Write-Host ("CSharp vs winget : {0}" -f $csFailCount) +Write-Host ("Rust vs winget : {0}" -f $rustFailCount) +Write-Host ("Rust vs CSharp : {0}" -f $implementationFailCount) + +if ($FixturePath) { + $fixture = [ordered]@{ + schema = "pinget-parity/list/v2" + machine = $machine + invocation = [ordered]@{ + pingetCs = $PingetCs + pingetRust = $PingetRust + winget = $Winget + pingetAppRoot = $env:PINGET_APPROOT + useSystemWingetAppRoot = [bool]$UseSystemWingetAppRoot + includeUnknown = [bool]$IncludeUnknown + } + winget = [ordered]@{ exitCode = $wingetResult.ExitCode; rows = $wingetResult.Rows; raw = $wingetResult.Raw } + pingetCs = [ordered]@{ exitCode = $csResult.ExitCode; rows = $csResult.Rows; raw = $csResult.Raw } + pingetRust = [ordered]@{ exitCode = $rustResult.ExitCode; rows = $rustResult.Rows; raw = $rustResult.Raw } + diff = [ordered]@{ + csharpVsWinget = [ordered]@{ + correlationMiss = $csDiff.CorrelationMiss + versionMismatch = $csDiff.VersionMismatch + sourceMismatch = $csDiff.SourceMismatch + availableDelta = $csDiff.AvailableDelta + duplicateId = $csDiff.DuplicateId + sharedDuplicateId = $csDiff.SharedDuplicateId + onlyInWinget = $csDiff.OnlyInWinget + onlyInPinget = $csDiff.OnlyInPinget + matching = $csDiff.Matching.Count + } + rustVsWinget = [ordered]@{ + correlationMiss = $rustDiff.CorrelationMiss + versionMismatch = $rustDiff.VersionMismatch + sourceMismatch = $rustDiff.SourceMismatch + availableDelta = $rustDiff.AvailableDelta + duplicateId = $rustDiff.DuplicateId + sharedDuplicateId = $rustDiff.SharedDuplicateId + onlyInWinget = $rustDiff.OnlyInWinget + onlyInPinget = $rustDiff.OnlyInPinget + matching = $rustDiff.Matching.Count + } + rustVsCsharp = [ordered]@{ + metadataMismatch = $implementationDiff.MetadataMismatch + onlyInRust = $implementationDiff.OnlyInLeft + onlyInCSharp = $implementationDiff.OnlyInRight + duplicateInRust = $implementationDiff.DuplicateLeft + duplicateInCSharp = $implementationDiff.DuplicateRight + matching = $implementationDiff.Matching.Count + } + } + } + $fixture | ConvertTo-Json -Depth 24 | Set-Content -LiteralPath $FixturePath -Encoding UTF8 + Write-Host "Fixture written to: $FixturePath" +} + +if ($FailOnDiff -and ($failCount -gt 0 -or $parserBroken)) { + throw "compare-list: $failCount non-cosmetic differences found." +} From 66ec8fab85f311ce78fd85f00aa685c5b9a1facc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Mon, 8 Jun 2026 09:04:40 -0400 Subject: [PATCH 2/6] Show install progress phases Emit coarse install/update progress from the Rust core and have the CLI print phase messages before long-running download and installer waits. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/crates/pinget-cli/src/main.rs | 56 ++++++++++++++++++++++++++---- rust/crates/pinget-core/src/lib.rs | 54 ++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 6 deletions(-) diff --git a/rust/crates/pinget-cli/src/main.rs b/rust/crates/pinget-cli/src/main.rs index 0e0dd4c..fabde27 100644 --- a/rust/crates/pinget-cli/src/main.rs +++ b/rust/crates/pinget-cli/src/main.rs @@ -7,9 +7,10 @@ use std::path::PathBuf; use anyhow::{Result, anyhow, bail}; use clap::{Args, Parser, Subcommand}; use pinget_core::{ - CacheWarmResult, Documentation, InstallRequest, InstallResult, InstallerMode, ListMatch, ListQuery, ListResponse, - PackageQuery, PinRecord, PinType, RepairRequest, Repository, SearchMatch, SearchResponse, ShowResult, SourceKind, - SourceRecord, SourceUpdateResult, UninstallRequest, VersionsResult, + CacheWarmResult, Documentation, InstallProgress, InstallRequest, InstallResult, InstallerMode, ListMatch, + ListQuery, ListResponse, PackageQuery, PinRecord, PinType, RepairRequest, Repository, RepositoryOptions, + SearchMatch, SearchResponse, ShowResult, SourceKind, SourceRecord, SourceUpdateResult, UninstallRequest, + VersionsResult, }; const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -597,12 +598,16 @@ fn run() -> Result<()> { } } Commands::Upgrade(args) => { - let mut repository = Repository::open()?; let do_install = args.all || args.query.is_some() || args.query_option.is_some() || args.id.is_some() || args.name.is_some(); + let mut repository = if do_install { + open_repository_with_install_progress()? + } else { + Repository::open()? + }; if do_install && !cfg!(windows) { print_warnings(&[UPGRADE_UNSUPPORTED_WARNING.to_owned()]); println!("No changes were made."); @@ -953,7 +958,7 @@ fn run() -> Result<()> { } } Commands::Install(args) => { - let mut repository = Repository::open()?; + let mut repository = open_repository_with_install_progress()?; let mode = if args.interactive { InstallerMode::Interactive } else if args.silent { @@ -1004,7 +1009,7 @@ fn run() -> Result<()> { print_install_result(&result); } Commands::Repair(args) => { - let mut repository = Repository::open()?; + let mut repository = open_repository_with_install_progress()?; let mode = if args.interactive { InstallerMode::Interactive } else if args.silent { @@ -1476,6 +1481,45 @@ fn print_warnings(warnings: &[String]) { } } +fn open_repository_with_install_progress() -> Result { + Repository::open_with_options(RepositoryOptions::for_current_user()?.with_install_progress(print_install_progress)) +} + +fn print_install_progress(progress: &InstallProgress) { + match progress { + InstallProgress::ResolvingManifest => write_stderr_line(format_args!("Resolving package manifest...")), + InstallProgress::ResolvedManifest { package_id, version } => { + write_stderr_line(format_args!("Resolved {package_id} {version}.")); + } + InstallProgress::CheckingInstalled => write_stderr_line(format_args!("Checking installed package state...")), + InstallProgress::InstallingDependencies => write_stderr_line(format_args!("Checking package dependencies...")), + InstallProgress::SelectingInstaller => { + write_stderr_line(format_args!("Selecting applicable installer for this system...")); + } + InstallProgress::DownloadingInstaller { url, path } => { + write_stderr_line(format_args!( + "Downloading installer from {url} to {}...", + path.display() + )); + } + InstallProgress::VerifyingInstaller { path } => { + write_stderr_line(format_args!("Verifying installer hash for {}...", path.display())); + } + InstallProgress::StartingInstaller { installer_type, path } => { + write_stderr_line(format_args!( + "Starting {installer_type} installer: {}...", + path.display() + )); + } + InstallProgress::WaitingForInstaller { installer_type } => { + write_stderr_line(format_args!("Waiting for {installer_type} installer to finish...")); + } + InstallProgress::InstallerFinished { exit_code } => { + write_stderr_line(format_args!("Installer exited with code {exit_code}.")); + } + } +} + fn write_stderr_line(args: fmt::Arguments<'_>) { let mut stderr = io::stderr().lock(); if writeln!(stderr, "{args}").is_err() {} diff --git a/rust/crates/pinget-core/src/lib.rs b/rust/crates/pinget-core/src/lib.rs index 02ee3b0..0aa0d20 100644 --- a/rust/crates/pinget-core/src/lib.rs +++ b/rust/crates/pinget-core/src/lib.rs @@ -160,6 +160,7 @@ pub struct RepositoryOptions { pub app_root: PathBuf, pub user_agent: String, pub pre_indexed_source_auto_update_interval: Option, + pub install_progress: Option, } impl RepositoryOptions { @@ -169,6 +170,7 @@ impl RepositoryOptions { app_root: app_root.into(), user_agent: DEFAULT_USER_AGENT.to_owned(), pre_indexed_source_auto_update_interval: Some(Duration::minutes(DEFAULT_PREINDEXED_AUTO_UPDATE_MINUTES)), + install_progress: None, } } @@ -190,6 +192,15 @@ impl RepositoryOptions { self.pre_indexed_source_auto_update_interval = interval; self } + + /// Receives coarse install/update phase notifications. Intended for CLIs + /// and host applications that need to show progress while downloads or + /// silent installers are running. + #[must_use] + pub fn with_install_progress(mut self, progress: fn(&InstallProgress)) -> Self { + self.install_progress = Some(progress); + self + } } #[derive(Debug, Clone, Default)] @@ -670,6 +681,20 @@ pub struct InstallResult { pub warnings: Vec, } +#[derive(Debug, Clone)] +pub enum InstallProgress { + ResolvingManifest, + ResolvedManifest { package_id: String, version: String }, + CheckingInstalled, + InstallingDependencies, + SelectingInstaller, + DownloadingInstaller { url: String, path: PathBuf }, + VerifyingInstaller { path: PathBuf }, + StartingInstaller { installer_type: String, path: PathBuf }, + WaitingForInstaller { installer_type: String }, + InstallerFinished { exit_code: i32 }, +} + impl InstallerSwitches { fn with_fallback(&self, fallback: &Self) -> Self { Self { @@ -845,6 +870,7 @@ pub struct Repository { client: Client, store: SourceStore, use_system_winget_sources: bool, + install_progress: Option, pre_indexed_source_auto_update_interval: Option, preindexed_refresh_attempts: HashMap, } @@ -920,11 +946,18 @@ impl Repository { client, store, use_system_winget_sources, + install_progress: options.install_progress, pre_indexed_source_auto_update_interval: options.pre_indexed_source_auto_update_interval, preindexed_refresh_attempts: HashMap::new(), }) } + fn emit_install_progress(&self, progress: InstallProgress) { + if let Some(callback) = self.install_progress { + callback(&progress); + } + } + pub fn app_root(&self) -> &Path { &self.app_root } @@ -2061,8 +2094,13 @@ impl Repository { }); let dest = download_dir.join(filename); + self.emit_install_progress(InstallProgress::DownloadingInstaller { + url: url.to_owned(), + path: dest.clone(), + }); let actual_hash = download_installer_to_file(&self.user_agent, url, &dest)?; + self.emit_install_progress(InstallProgress::VerifyingInstaller { path: dest.clone() }); if let Err(error) = verify_installer_hash_hex(installer.sha256.as_deref(), &actual_hash, request.ignore_security_hash) { @@ -2095,7 +2133,12 @@ impl Repository { } pub fn install_request(&mut self, request: &InstallRequest) -> Result { + self.emit_install_progress(InstallProgress::ResolvingManifest); let manifest = self.resolve_manifest_for_install(request)?; + self.emit_install_progress(InstallProgress::ResolvedManifest { + package_id: manifest.id.clone(), + version: manifest.version.clone(), + }); if !package_actions_supported() { let installer_type = select_installer(&manifest.installers, &request.query) .and_then(|installer| installer.installer_type) @@ -2109,12 +2152,14 @@ impl Repository { )); } + self.emit_install_progress(InstallProgress::CheckingInstalled); let existing_match = self.find_installed_package_for_install(request, &manifest)?; if let Some(no_op_result) = Self::create_install_no_op_result(request, &manifest, existing_match.as_ref()) { return Ok(no_op_result); } Self::ensure_package_agreements_accepted(&manifest, request)?; + self.emit_install_progress(InstallProgress::InstallingDependencies); self.install_dependencies(&manifest, request, &mut HashSet::new())?; if request.dependencies_only { @@ -2130,6 +2175,7 @@ impl Repository { }); } + self.emit_install_progress(InstallProgress::SelectingInstaller); let installer = select_installer(&manifest.installers, &request.query) .ok_or_else(|| anyhow!("No applicable installer found"))?; @@ -2154,7 +2200,15 @@ impl Repository { let installer_type = installer.installer_type.as_deref().unwrap_or("exe").to_lowercase(); + self.emit_install_progress(InstallProgress::StartingInstaller { + installer_type: installer_type.clone(), + path: installer_path.clone(), + }); + self.emit_install_progress(InstallProgress::WaitingForInstaller { + installer_type: installer_type.clone(), + }); let exit_code = dispatch_installer(&installer_path, &installer_type, request, &manifest, &installer)?; + self.emit_install_progress(InstallProgress::InstallerFinished { exit_code }); Ok(InstallResult { package_id: manifest.id.clone(), From 2501ecf4d30435690653be11cab4ad4eef006f80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Mon, 8 Jun 2026 09:24:03 -0400 Subject: [PATCH 3/6] Use WinGet ARP identity metadata Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CoreTests.cs | 49 ++++++ .../InstalledPackages.cs | 4 + dotnet/src/Devolutions.Pinget.Core/Models.cs | 2 + .../src/Devolutions.Pinget.Core/Repository.cs | 19 ++- rust/crates/pinget-core/src/lib.rs | 147 ++++++++++++++++++ 5 files changed, 220 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs index 73ed3e4..e33cc0a 100644 --- a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs +++ b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs @@ -956,6 +956,55 @@ out string? sourceName Assert.Null(sourceName); } + [Fact] + public void ListMatchFromInstalled_UsesWinGetArpIdentityMetadata() + { + var package = new InstalledPackage + { + Name = "tessl", + LocalId = @"ARP\User\X64\tessl.tessl_api.winget.pro", + InstalledVersion = "0.77.0", + Publisher = "tessl.io", + Scope = "User", + InstallerCategory = "portable", + WinGetPackageIdentifier = "tessl.tessl", + WinGetSourceIdentifier = "api.winget.pro", + }; + + var item = Repository.ListMatchFromInstalledForTesting(package, []); + + Assert.Equal("tessl.tessl", item.Id); + Assert.Equal("api.winget.pro", item.SourceName); + } + + [Fact] + public void ListMatchFromInstalled_MapsWinGetArpSourceIdentifierToConfiguredName() + { + var package = new InstalledPackage + { + Name = "tessl", + LocalId = @"ARP\User\X64\tessl.tessl_api.winget.pro", + InstalledVersion = "0.77.0", + Publisher = "tessl.io", + Scope = "User", + InstallerCategory = "portable", + WinGetPackageIdentifier = "tessl.tessl", + WinGetSourceIdentifier = "api.winget.pro", + }; + SourceRecord source = new() + { + Name = "winget.pro", + Kind = SourceKind.Rest, + Arg = "https://api.winget.pro/4259fd23-6fcd-46bf-9287-be8833cfbdd5", + Identifier = "api.winget.pro", + }; + + var item = Repository.ListMatchFromInstalledForTesting(package, [source]); + + Assert.Equal("tessl.tessl", item.Id); + Assert.Equal("winget.pro", item.SourceName); + } + } public class PinStoreTests diff --git a/dotnet/src/Devolutions.Pinget.Core/InstalledPackages.cs b/dotnet/src/Devolutions.Pinget.Core/InstalledPackages.cs index 0bb4adc..ed94d3d 100644 --- a/dotnet/src/Devolutions.Pinget.Core/InstalledPackages.cs +++ b/dotnet/src/Devolutions.Pinget.Core/InstalledPackages.cs @@ -185,6 +185,8 @@ private static void CollectArpPackages( var packageFamilyName = subkey.GetValue("PackageFamilyName") as string; var productCode = subkey.GetValue("ProductCode") as string; var upgradeCode = subkey.GetValue("UpgradeCode") as string; + var wingetPackageIdentifier = subkey.GetValue("WinGetPackageIdentifier") as string; + var wingetSourceIdentifier = subkey.GetValue("WinGetSourceIdentifier") as string; var localId = $@"ARP\{scopeLabel}\{effectiveArch}\{subkeyName}"; // Honor the WinGet ARP signal so uninstall flows through the @@ -246,6 +248,8 @@ private static void CollectArpPackages( Scope = scopeLabel, InstallerCategory = installerCategory, InstallLocation = installLocation, + WinGetPackageIdentifier = wingetPackageIdentifier, + WinGetSourceIdentifier = wingetSourceIdentifier, PackageFamilyNames = packageFamilyNames, ProductCodes = productCodes, UpgradeCodes = upgradeCodes, diff --git a/dotnet/src/Devolutions.Pinget.Core/Models.cs b/dotnet/src/Devolutions.Pinget.Core/Models.cs index 56e6a5d..812c909 100644 --- a/dotnet/src/Devolutions.Pinget.Core/Models.cs +++ b/dotnet/src/Devolutions.Pinget.Core/Models.cs @@ -651,6 +651,8 @@ internal record InstalledPackage public string? Scope { get; init; } public string? InstallerCategory { get; init; } public string? InstallLocation { get; init; } + public string? WinGetPackageIdentifier { get; init; } + public string? WinGetSourceIdentifier { get; init; } public List PackageFamilyNames { get; init; } = []; public List ProductCodes { get; init; } = []; public List UpgradeCodes { get; init; } = []; diff --git a/dotnet/src/Devolutions.Pinget.Core/Repository.cs b/dotnet/src/Devolutions.Pinget.Core/Repository.cs index 5b10831..341dc12 100644 --- a/dotnet/src/Devolutions.Pinget.Core/Repository.cs +++ b/dotnet/src/Devolutions.Pinget.Core/Repository.cs @@ -3659,6 +3659,12 @@ private static int ListSortWeight(InstalledPackage pkg) } private ListMatch ListMatchFromInstalled(InstalledPackage pkg) + => ListMatchFromInstalled(pkg, _store.Sources); + + internal static ListMatch ListMatchFromInstalledForTesting(InstalledPackage pkg, IReadOnlyList sources) + => ListMatchFromInstalled(pkg, sources); + + private static ListMatch ListMatchFromInstalled(InstalledPackage pkg, IReadOnlyList sources) { string? availableVersion = null; if (pkg.Correlated?.Version is string av) @@ -3673,9 +3679,16 @@ private ListMatch ListMatchFromInstalled(InstalledPackage pkg) string packageId = pkg.Correlated?.Id ?? pkg.LocalId; string? sourceName = pkg.Correlated?.SourceName; + if (sourceName is null && !string.IsNullOrWhiteSpace(pkg.WinGetPackageIdentifier)) + { + packageId = pkg.WinGetPackageIdentifier; + if (!string.IsNullOrWhiteSpace(pkg.WinGetSourceIdentifier)) + sourceName = SourceNameFromIdentifier(pkg.WinGetSourceIdentifier, sources); + } + if (sourceName is null && TryGetWinGetPackageIdentityFromLocalId( pkg.LocalId, - _store.Sources, + sources, out string? localPackageId, out string? localSourceName)) { @@ -3739,6 +3752,10 @@ internal static List SuppressDuplicateAvailableVersions(IReadOnlyList .ToList(); } + private static string SourceNameFromIdentifier(string identifier, IReadOnlyList sources) => + sources.FirstOrDefault(source => source.Identifier.Equals(identifier, StringComparison.OrdinalIgnoreCase))?.Name + ?? identifier; + internal static bool TryGetWinGetPackageIdentityFromLocalId( string localId, IReadOnlyList sources, diff --git a/rust/crates/pinget-core/src/lib.rs b/rust/crates/pinget-core/src/lib.rs index 0aa0d20..51784f1 100644 --- a/rust/crates/pinget-core/src/lib.rs +++ b/rust/crates/pinget-core/src/lib.rs @@ -749,6 +749,8 @@ struct InstalledPackage { scope: Option, installer_category: Option, install_location: Option, + winget_package_identifier: Option, + winget_source_identifier: Option, package_family_names: Vec, product_codes: Vec, upgrade_codes: Vec, @@ -3804,6 +3806,20 @@ fn list_match_from_installed(package: InstalledPackage, sources: &[SourceRecord] .map(|candidate| candidate.id.clone()) .unwrap_or_else(|| package.local_id.clone()); + if source_name.is_none() + && let Some(package_id) = package + .winget_package_identifier + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + id = package_id.to_owned(); + source_name = package + .winget_source_identifier + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(|identifier| source_name_from_identifier(identifier, sources)); + } + if source_name.is_none() && let Some((local_package_id, local_source_name)) = winget_package_identity_from_local_id(&package.local_id, sources) @@ -3880,6 +3896,14 @@ fn suppress_duplicate_available_versions(matches: Vec) -> Vec String { + sources + .iter() + .find(|source| source.identifier.eq_ignore_ascii_case(identifier)) + .map(|source| source.name.clone()) + .unwrap_or_else(|| identifier.to_owned()) +} + fn winget_package_identity_from_local_id(local_id: &str, sources: &[SourceRecord]) -> Option<(String, String)> { let package_id_start_index = local_id.rfind('\\').map_or(0, |index| index + 1); for source in sources { @@ -4662,6 +4686,8 @@ fn collect_uninstall_view( let installed_version = read_reg_string(&subkey, "DisplayVersion").unwrap_or_else(|| "Unknown".to_owned()); let publisher = read_reg_string(&subkey, "Publisher"); let install_location = read_reg_string(&subkey, "InstallLocation"); + let winget_package_identifier = read_reg_string(&subkey, "WinGetPackageIdentifier"); + let winget_source_identifier = read_reg_string(&subkey, "WinGetSourceIdentifier"); let package_family_names = read_reg_string(&subkey, "PackageFamilyName") .into_iter() .collect::>(); @@ -4718,6 +4744,8 @@ fn collect_uninstall_view( scope: Some(scope.to_owned()), installer_category, install_location, + winget_package_identifier, + winget_source_identifier, package_family_names, product_codes, upgrade_codes, @@ -4791,6 +4819,8 @@ fn collect_appmodel_packages( scope: Some(scope.to_owned()), installer_category: Some("msix".to_owned()), install_location, + winget_package_identifier: None, + winget_source_identifier: None, package_family_names: vec![metadata.family_name], product_codes: Vec::new(), upgrade_codes: Vec::new(), @@ -11646,6 +11676,8 @@ Installers: channel: None, match_criteria: None, }), + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: true, correlated_lacks_compatible_installer: false, @@ -11699,6 +11731,8 @@ Installers: channel: None, match_criteria: None, }), + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: true, @@ -11856,6 +11890,8 @@ Installers: product_codes: vec!["git_is1".to_owned()], upgrade_codes: Vec::new(), correlated: None, + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -13097,6 +13133,8 @@ Installers: product_codes: Vec::new(), upgrade_codes: Vec::new(), correlated: None, + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -13140,6 +13178,8 @@ Installers: product_codes: Vec::new(), upgrade_codes: Vec::new(), correlated: None, + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -13182,6 +13222,8 @@ Installers: product_codes: Vec::new(), upgrade_codes: Vec::new(), correlated: None, + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -13231,6 +13273,8 @@ Installers: product_codes: Vec::new(), upgrade_codes: Vec::new(), correlated: None, + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -13293,6 +13337,8 @@ Installers: product_codes: Vec::new(), upgrade_codes: Vec::new(), correlated: None, + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -13332,6 +13378,8 @@ Installers: product_codes: Vec::new(), upgrade_codes: Vec::new(), correlated: None, + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -13379,6 +13427,8 @@ Installers: product_codes: Vec::new(), upgrade_codes: Vec::new(), correlated: None, + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -13411,6 +13461,8 @@ Installers: product_codes: Vec::new(), upgrade_codes: Vec::new(), correlated: None, + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -13452,6 +13504,8 @@ Installers: channel: None, match_criteria: None, }), + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -13483,6 +13537,8 @@ Installers: channel: None, match_criteria: None, }), + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -13933,6 +13989,8 @@ Installers: product_codes: Vec::new(), upgrade_codes: Vec::new(), correlated: None, + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -13949,6 +14007,8 @@ Installers: product_codes: Vec::new(), upgrade_codes: Vec::new(), correlated: None, + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -13965,6 +14025,8 @@ Installers: product_codes: Vec::new(), upgrade_codes: Vec::new(), correlated: None, + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -14887,6 +14949,8 @@ Installers: channel: None, match_criteria: None, }), + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -14980,6 +15044,8 @@ Installers: channel: None, match_criteria: Some("ProductCode".to_owned()), }), + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -15010,6 +15076,8 @@ Installers: product_codes: Vec::new(), upgrade_codes: Vec::new(), correlated: None, + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -15038,6 +15106,73 @@ Installers: assert_eq!(item.source_name.as_deref(), Some("winget")); } + #[test] + fn list_match_from_installed_uses_winget_arp_identity_metadata() { + let package = InstalledPackage { + name: "tessl".to_owned(), + local_id: r"ARP\User\X64\tessl.tessl_api.winget.pro".to_owned(), + installed_version: "0.77.0".to_owned(), + publisher: Some("tessl.io".to_owned()), + scope: Some("User".to_owned()), + installer_category: Some("portable".to_owned()), + install_location: None, + package_family_names: Vec::new(), + product_codes: Vec::new(), + upgrade_codes: Vec::new(), + correlated: None, + winget_package_identifier: Some("tessl.tessl".to_owned()), + winget_source_identifier: Some("api.winget.pro".to_owned()), + installed_version_canonical: false, + correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, + }; + + let item = list_match_from_installed(package, &[]); + + assert_eq!(item.id, "tessl.tessl"); + assert_eq!(item.source_name.as_deref(), Some("api.winget.pro")); + } + + #[test] + fn list_match_from_installed_maps_winget_arp_source_identifier_to_configured_name() { + let package = InstalledPackage { + name: "tessl".to_owned(), + local_id: r"ARP\User\X64\tessl.tessl_api.winget.pro".to_owned(), + installed_version: "0.77.0".to_owned(), + publisher: Some("tessl.io".to_owned()), + scope: Some("User".to_owned()), + installer_category: Some("portable".to_owned()), + install_location: None, + package_family_names: Vec::new(), + product_codes: Vec::new(), + upgrade_codes: Vec::new(), + correlated: None, + winget_package_identifier: Some("tessl.tessl".to_owned()), + winget_source_identifier: Some("api.winget.pro".to_owned()), + installed_version_canonical: false, + correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, + }; + let sources = vec![SourceRecord { + name: "winget.pro".to_owned(), + kind: SourceKind::Rest, + arg: "https://api.winget.pro/4259fd23-6fcd-46bf-9287-be8833cfbdd5".to_owned(), + identifier: "api.winget.pro".to_owned(), + trust_level: "Trusted".to_owned(), + explicit: false, + priority: 0, + last_update: None, + source_version: None, + etag: None, + last_modified: None, + }]; + + let item = list_match_from_installed(package, &sources); + + assert_eq!(item.id, "tessl.tessl"); + assert_eq!(item.source_name.as_deref(), Some("winget.pro")); + } + #[test] fn list_match_from_installed_keeps_scanning_portable_source_identifiers() { let package = InstalledPackage { @@ -15052,6 +15187,8 @@ Installers: product_codes: Vec::new(), upgrade_codes: Vec::new(), correlated: None, + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -15230,6 +15367,8 @@ Installers: channel: None, match_criteria: Some("PackageFamilyName".to_owned()), }), + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -15265,6 +15404,8 @@ Installers: channel: None, match_criteria: None, }), + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -15300,6 +15441,8 @@ Installers: channel: None, match_criteria: Some("PackageFamilyName".to_owned()), }), + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -15355,6 +15498,8 @@ Installers: channel: None, match_criteria: None, }), + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: canonical, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -15405,6 +15550,8 @@ Installers: product_codes: Vec::new(), upgrade_codes: Vec::new(), correlated: None, + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, From efe4ca91db7d11a5eb0a4db4fbe9a68ce8e6812f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Mon, 8 Jun 2026 09:58:52 -0400 Subject: [PATCH 4/6] Mirror system WinGet sources privately Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CoreTests.cs | 43 +++ dotnet/src/Devolutions.Pinget.Core/Models.cs | 10 +- .../PreIndexedSource.cs | 16 +- .../src/Devolutions.Pinget.Core/Repository.cs | 83 +++-- .../src/Devolutions.Pinget.Core/RestSource.cs | 8 +- .../Devolutions.Pinget.Core/SourceStore.cs | 95 +++++- rust/crates/pinget-core/src/lib.rs | 311 +++++++++++++++--- 7 files changed, 465 insertions(+), 101 deletions(-) diff --git a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs index e33cc0a..42c9b3a 100644 --- a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs +++ b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs @@ -366,6 +366,49 @@ public void RepositoryOpen_UsesCustomAppRoot() } } + [Fact] + public void SystemWingetMirror_UsesPrivateCacheAndPreservesMetadata() + { + var appRoot = TestPaths.CreateTempAppRoot(); + var originalRunner = SystemWingetSourceStore.CommandRunner; + try + { + SystemWingetSourceStore.CommandRunner = _ => new WingetCommandResult( + 0, + """ +{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"} +""", + ""); + + var (mode, store) = SourceStoreManager.LoadEffective(appRoot, SourceMode.SystemWingetMirror); + + Assert.Equal(EffectiveSourceMode.SystemWingetMirror, mode); + Assert.Single(store.Sources); + Assert.True(File.Exists(Path.Combine(appRoot, "system-sources.json"))); + Assert.False(File.Exists(Path.Combine(appRoot, "sources.json"))); + + var source = store.Sources[0]; + source.LastUpdate = DateTime.UtcNow; + source.SourceVersion = "cached-contract"; + source.ETag = "\"etag\""; + source.LastModified = "Wed, 03 Jun 2026 12:00:00 GMT"; + SourceStoreManager.SaveSystemWingetMirrorStore(appRoot, store); + + var refreshed = SourceStoreManager.RefreshSystemWingetMirrorStore(appRoot); + var refreshedSource = Assert.Single(refreshed.Sources); + + Assert.Equal("cached-contract", refreshedSource.SourceVersion); + Assert.Equal("\"etag\"", refreshedSource.ETag); + Assert.Equal(Path.Combine(appRoot, "sources", "api.contoso.test"), SourceStoreManager.SourceStateDir(refreshedSource, appRoot, identifierKeyed: true)); + Assert.Equal(Path.Combine(appRoot, "sources", "contoso"), SourceStoreManager.SourceStateDir(refreshedSource, appRoot)); + } + finally + { + SystemWingetSourceStore.CommandRunner = originalRunner; + TestPaths.DeleteAppRoot(appRoot); + } + } + [Fact] public void EditSourceAndResetSource_PreserveCustomMetadata() { diff --git a/dotnet/src/Devolutions.Pinget.Core/Models.cs b/dotnet/src/Devolutions.Pinget.Core/Models.cs index 812c909..50f1059 100644 --- a/dotnet/src/Devolutions.Pinget.Core/Models.cs +++ b/dotnet/src/Devolutions.Pinget.Core/Models.cs @@ -16,6 +16,13 @@ public enum PinType Gating } +public enum SourceMode +{ + Auto, + Private, + SystemWingetMirror +} + public record SourceRecord { public required string Name { get; init; } @@ -37,9 +44,10 @@ public record SourceRecord public record RepositoryOptions { /// - /// Storage root for source state and caches. A null value uses the real Desktop App Installer / WinGet state on Windows. + /// Storage root for Pinget state and caches. /// public string? AppRoot { get; init; } + public SourceMode SourceMode { get; init; } = SourceMode.Auto; public string UserAgent { get; init; } = "pinget-dotnet/0.1"; public Action? Diagnostics { get; init; } diff --git a/dotnet/src/Devolutions.Pinget.Core/PreIndexedSource.cs b/dotnet/src/Devolutions.Pinget.Core/PreIndexedSource.cs index 22ca99d..019c092 100644 --- a/dotnet/src/Devolutions.Pinget.Core/PreIndexedSource.cs +++ b/dotnet/src/Devolutions.Pinget.Core/PreIndexedSource.cs @@ -12,22 +12,22 @@ internal static class PreIndexedSource { private static readonly string[] MsixCandidates = ["source2.msix", "source.msix"]; - public static string IndexPath(SourceRecord source, string? appRoot = null) + public static string IndexPath(SourceRecord source, string? appRoot = null, bool identifierKeyed = false) { - var stateDir = SourceStoreManager.SourceStateDir(source, appRoot); + var stateDir = SourceStoreManager.SourceStateDir(source, appRoot, identifierKeyed); return Path.Combine(stateDir, "index.db"); } - public static string Update(HttpClient client, SourceRecord source, string? appRoot = null) + public static string Update(HttpClient client, SourceRecord source, string? appRoot = null, bool identifierKeyed = false) { - var stateDir = SourceStoreManager.SourceStateDir(source, appRoot); + var stateDir = SourceStoreManager.SourceStateDir(source, appRoot, identifierKeyed); Directory.CreateDirectory(stateDir); Exception? lastError = null; foreach (var candidate in MsixCandidates) { var url = $"{source.Arg.TrimEnd('/')}/{candidate}"; - var hasLocalIndex = File.Exists(IndexPath(source, appRoot)); + var hasLocalIndex = File.Exists(IndexPath(source, appRoot, identifierKeyed)); try { if (hasLocalIndex && TrySkipUnchangedBySourceVersion(client, url, source, candidate, out var detail)) @@ -59,7 +59,7 @@ public static string Update(HttpClient client, SourceRecord source, string? appR var indexEntry = archive.GetEntry("Public/index.db") ?? throw new InvalidOperationException($"No Public/index.db in {candidate}"); - var indexPath = IndexPath(source, appRoot); + var indexPath = IndexPath(source, appRoot, identifierKeyed); var tempIndexPath = Path.Combine(stateDir, $"index.{Guid.NewGuid():N}.tmp"); try { @@ -571,9 +571,9 @@ public static byte[] GetCachedSourceFile( return bytes; } - public static byte[] GetCachedSourceFileFromMsix(SourceRecord source, string relativePath, string? appRoot = null) + public static byte[] GetCachedSourceFileFromMsix(SourceRecord source, string relativePath, string? appRoot = null, bool identifierKeyed = false) { - var stateDir = SourceStoreManager.SourceStateDir(source, appRoot); + var stateDir = SourceStoreManager.SourceStateDir(source, appRoot, identifierKeyed); foreach (var candidate in MsixCandidates) { var msixPath = Path.Combine(stateDir, candidate); diff --git a/dotnet/src/Devolutions.Pinget.Core/Repository.cs b/dotnet/src/Devolutions.Pinget.Core/Repository.cs index 341dc12..513fb16 100644 --- a/dotnet/src/Devolutions.Pinget.Core/Repository.cs +++ b/dotnet/src/Devolutions.Pinget.Core/Repository.cs @@ -28,10 +28,12 @@ public class Repository : IDisposable private readonly string _appRoot; private readonly HttpClient _client; - private readonly bool _useSystemWingetSources; private readonly Action? _diagnostics; private readonly TimeSpan? _preIndexedSourceAutoUpdateInterval; private SourceStore _store; + private readonly EffectiveSourceMode _sourceMode; + private bool UsesSystemWingetSources => _sourceMode != EffectiveSourceMode.Private; + private bool MirrorsSystemWingetSources => _sourceMode == EffectiveSourceMode.SystemWingetMirror; private static readonly ConcurrentDictionary s_preIndexedRefreshLocks = new(StringComparer.OrdinalIgnoreCase); private static readonly TimeSpan s_preIndexedRefreshRetryInterval = TimeSpan.FromMinutes(5); private readonly object _preIndexedRefreshAttemptGate = new(); @@ -41,14 +43,14 @@ private Repository( string appRoot, HttpClient client, SourceStore store, - bool useSystemWingetSources, + EffectiveSourceMode sourceMode, Action? diagnostics, TimeSpan? preIndexedSourceAutoUpdateInterval) { _appRoot = appRoot; _client = client; _store = store; - _useSystemWingetSources = useSystemWingetSources; + _sourceMode = sourceMode; _diagnostics = diagnostics; _preIndexedSourceAutoUpdateInterval = preIndexedSourceAutoUpdateInterval; } @@ -60,10 +62,13 @@ public static Repository Open(RepositoryOptions? options = null) { options ??= new RepositoryOptions(); EnsureSqliteNativeLibraryLoaded(); - var appRoot = SourceStoreManager.NormalizeAppRoot(options.AppRoot ?? Environment.GetEnvironmentVariable(AppRootEnvironmentVariable)); + var requestedAppRoot = options.AppRoot ?? Environment.GetEnvironmentVariable(AppRootEnvironmentVariable); + var appRoot = SourceStoreManager.NormalizeAppRoot(requestedAppRoot); SourceStoreManager.EnsureAppDirs(appRoot); - var useSystemWingetSources = SourceStoreManager.UsesSystemWingetSourceCommands(appRoot); - var store = SourceStoreManager.Load(appRoot); + var requestedSourceMode = options.SourceMode == SourceMode.Auto && requestedAppRoot is not null + ? SourceMode.Private + : options.SourceMode; + var (sourceMode, store) = SourceStoreManager.LoadEffective(appRoot, requestedSourceMode); var client = new HttpClient(new SocketsHttpHandler { AutomaticDecompression = System.Net.DecompressionMethods.None, @@ -71,7 +76,7 @@ public static Repository Open(RepositoryOptions? options = null) }); client.Timeout = Timeout.InfiniteTimeSpan; client.DefaultRequestHeaders.UserAgent.ParseAdd(options.UserAgent); - return new Repository(appRoot, client, store, useSystemWingetSources, options.Diagnostics, options.PreIndexedSourceAutoUpdateInterval); + return new Repository(appRoot, client, store, sourceMode, options.Diagnostics, options.PreIndexedSourceAutoUpdateInterval); } internal static IEnumerable GetSqliteNativeLibraryCandidates(string assemblyDirectory) @@ -177,7 +182,7 @@ public List ListSources() public void AddSource(string name, string arg, SourceKind kind, string trustLevel = "None", bool explicitSource = false, int priority = 0) { - if (_useSystemWingetSources) + if (UsesSystemWingetSources) { SystemWingetSourceStore.AddSource(name, arg, kind, NormalizeSourceTrustLevel(trustLevel), explicitSource); RefreshSystemWingetSources(); @@ -204,7 +209,7 @@ public void AddSource(string name, string arg, SourceKind kind, string trustLeve public void EditSource(string name, bool? explicitSource = null, string? trustLevel = null) { - if (_useSystemWingetSources) + if (UsesSystemWingetSources) throw new InvalidOperationException("Editing system WinGet sources is not supported by winget source commands."); var source = _store.Sources.FirstOrDefault(s => string.Equals(s.Name, name, StringComparison.OrdinalIgnoreCase)) @@ -221,7 +226,7 @@ public void EditSource(string name, bool? explicitSource = null, string? trustLe public void RemoveSource(string name) { - if (_useSystemWingetSources) + if (UsesSystemWingetSources) { SystemWingetSourceStore.RemoveSource(name); RefreshSystemWingetSources(); @@ -232,7 +237,7 @@ public void RemoveSource(string name) ?? throw new InvalidOperationException($"Source '{name}' not found."); _store.Sources.Remove(source); - var stateDir = SourceStoreManager.SourceStateDir(source, _appRoot); + var stateDir = SourceStoreManager.SourceStateDir(source, _appRoot, MirrorsSystemWingetSources); if (Directory.Exists(stateDir)) Directory.Delete(stateDir, recursive: true); SourceStoreManager.Save(_store, _appRoot); @@ -240,7 +245,7 @@ public void RemoveSource(string name) public void ResetSource(string name) { - if (_useSystemWingetSources) + if (UsesSystemWingetSources) { SystemWingetSourceStore.ResetSource(name); RefreshSystemWingetSources(); @@ -252,7 +257,7 @@ public void ResetSource(string name) throw new InvalidOperationException($"Source '{name}' not found."); var source = _store.Sources[index]; - var stateDir = SourceStoreManager.SourceStateDir(source, _appRoot); + var stateDir = SourceStoreManager.SourceStateDir(source, _appRoot, MirrorsSystemWingetSources); if (Directory.Exists(stateDir)) Directory.Delete(stateDir, recursive: true); @@ -276,7 +281,7 @@ public void ResetSource(string name) public void ResetSources() { - if (_useSystemWingetSources) + if (UsesSystemWingetSources) { SystemWingetSourceStore.ResetSources(); RefreshSystemWingetSources(); @@ -285,7 +290,7 @@ public void ResetSources() foreach (var source in _store.Sources) { - var stateDir = SourceStoreManager.SourceStateDir(source, _appRoot); + var stateDir = SourceStoreManager.SourceStateDir(source, _appRoot, MirrorsSystemWingetSources); if (Directory.Exists(stateDir)) Directory.Delete(stateDir, recursive: true); } @@ -366,9 +371,12 @@ public void EnsureSettingsFiles() public List UpdateSources(string? sourceName = null) { - if (_useSystemWingetSources) + if (_sourceMode == EffectiveSourceMode.SystemWingetDirect) return UpdateSystemWingetSources(sourceName); + if (MirrorsSystemWingetSources) + RefreshSystemWingetSources(); + var indexes = ResolveSourceIndexes(sourceName); var results = new List(); @@ -377,15 +385,15 @@ public List UpdateSources(string? sourceName = null) var source = _store.Sources[index]; var detail = source.Kind switch { - SourceKind.PreIndexed => PreIndexedSource.Update(_client, source, _appRoot), - SourceKind.Rest => RestSource.UpdateRest(_client, source, _appRoot), + SourceKind.PreIndexed => PreIndexedSource.Update(_client, source, _appRoot, MirrorsSystemWingetSources), + SourceKind.Rest => RestSource.UpdateRest(_client, source, _appRoot, MirrorsSystemWingetSources), _ => "Unknown source kind" }; source.LastUpdate = DateTime.UtcNow; results.Add(new SourceUpdateResult { Name = source.Name, Kind = source.Kind, Detail = detail }); } - SourceStoreManager.Save(_store, _appRoot); + SaveStore(); return results; } @@ -1509,7 +1517,7 @@ private record SourceSearchFailure(RepositoryWarning Warning, Exception Exceptio RestSource.RestInformation info; try { - info = RestSource.LoadInformation(_client, source, _appRoot); + info = RestSource.LoadInformation(_client, source, _appRoot, MirrorsSystemWingetSources); } catch (Exception ex) { @@ -1579,8 +1587,8 @@ private static string SourceRequestUri(SourceRecord source, string operation) => private string? SourceDiagnosticCachePath(SourceRecord source) => source.Kind switch { - SourceKind.PreIndexed => PreIndexedSource.IndexPath(source, _appRoot), - SourceKind.Rest => Path.Combine(SourceStoreManager.SourceStateDir(source, _appRoot), "rest_info.json"), + SourceKind.PreIndexed => PreIndexedSource.IndexPath(source, _appRoot, MirrorsSystemWingetSources), + SourceKind.Rest => Path.Combine(SourceStoreManager.SourceStateDir(source, _appRoot, MirrorsSystemWingetSources), "rest_info.json"), _ => null, }; @@ -1612,7 +1620,7 @@ private SqliteConnection OpenPreindexedConnection(int sourceIndex) => private SqliteConnection OpenPreindexedConnection(int sourceIndex, out PreIndexedRefreshOutcome refreshOutcome) { var source = _store.Sources[sourceIndex]; - var indexPath = PreIndexedSource.IndexPath(source, _appRoot); + var indexPath = PreIndexedSource.IndexPath(source, _appRoot, MirrorsSystemWingetSources); refreshOutcome = EnsurePreindexedIndex(sourceIndex, indexPath); var conn = new SqliteConnection($"Data Source={indexPath};Mode=ReadOnly;Pooling=False"); @@ -1672,7 +1680,7 @@ private bool IsPreindexedIndexStale(SourceRecord source, string indexPath) private string RefreshPreindexedSource(int sourceIndex, bool force, PreIndexedRefreshKind kind) { var source = _store.Sources[sourceIndex]; - var indexPath = PreIndexedSource.IndexPath(source, _appRoot); + var indexPath = PreIndexedSource.IndexPath(source, _appRoot, MirrorsSystemWingetSources); var lockKey = PreindexedRefreshLockKey(source); var refreshLock = s_preIndexedRefreshLocks.GetOrAdd(lockKey, _ => new object()); @@ -1683,10 +1691,10 @@ private string RefreshPreindexedSource(int sourceIndex, bool force, PreIndexedRe try { - var detail = PreIndexedSource.Update(_client, source, _appRoot); + var detail = PreIndexedSource.Update(_client, source, _appRoot, MirrorsSystemWingetSources); source.LastUpdate = DateTime.UtcNow; - if (!_useSystemWingetSources) - SourceStoreManager.Save(_store, _appRoot); + if (_sourceMode != EffectiveSourceMode.SystemWingetDirect) + SaveStore(); RecordPreindexedRefreshAttempt(lockKey, null, kind); return detail; } @@ -1916,7 +1924,7 @@ private List VersionsFromV2(int sourceIndex, long packageRowid, stri PackageQuery query) { var source = _store.Sources[sourceIndex]; - var info = RestSource.LoadInformation(_client, source, _appRoot); + var info = RestSource.LoadInformation(_client, source, _appRoot, MirrorsSystemWingetSources); var selected = SelectRestVersion(versions, query.Version, query.Channel, display.Id, source.Name); var (manifest, structuredDocument) = RestSource.FetchManifestWithDocuments(_client, source, info, packageId, selected.Version, selected.Channel); return (manifest, structuredDocument, []); @@ -2930,7 +2938,7 @@ private List CorrelateInstalledViaInstalledDb(List ins if (requestedSource is not null && !source.Name.Equals(requestedSource, StringComparison.OrdinalIgnoreCase)) continue; - var installedDbPath = SourceStoreManager.SourceInstalledDbPath(source, _appRoot); + var installedDbPath = SourceStoreManager.SourceInstalledDbPath(source, _appRoot, MirrorsSystemWingetSources); if (!File.Exists(installedDbPath)) continue; @@ -4008,8 +4016,21 @@ private List ResolveSourceIndexes(string? sourceName) private void RefreshSystemWingetSources() { - if (_useSystemWingetSources) - _store = SystemWingetSourceStore.Load(); + _store = _sourceMode switch + { + EffectiveSourceMode.Private => _store, + EffectiveSourceMode.SystemWingetDirect => SystemWingetSourceStore.Load(), + EffectiveSourceMode.SystemWingetMirror => SourceStoreManager.RefreshSystemWingetMirrorStore(_appRoot), + _ => _store, + }; + } + + private void SaveStore() + { + if (MirrorsSystemWingetSources) + SourceStoreManager.SaveSystemWingetMirrorStore(_appRoot, _store); + else + SourceStoreManager.Save(_store, _appRoot); } private List UpdateSystemWingetSources(string? sourceName) diff --git a/dotnet/src/Devolutions.Pinget.Core/RestSource.cs b/dotnet/src/Devolutions.Pinget.Core/RestSource.cs index 20e8d00..9a00f71 100644 --- a/dotnet/src/Devolutions.Pinget.Core/RestSource.cs +++ b/dotnet/src/Devolutions.Pinget.Core/RestSource.cs @@ -23,9 +23,9 @@ internal record RestInfoCache public RestInformation Value { get; init; } = new(); } - public static RestInformation LoadInformation(HttpClient client, SourceRecord source, string? appRoot = null) + public static RestInformation LoadInformation(HttpClient client, SourceRecord source, string? appRoot = null, bool identifierKeyed = false) { - var stateDir = SourceStoreManager.SourceStateDir(source, appRoot); + var stateDir = SourceStoreManager.SourceStateDir(source, appRoot, identifierKeyed); Directory.CreateDirectory(stateDir); var cachePath = Path.Combine(stateDir, "rest_info.json"); @@ -148,9 +148,9 @@ public static (Manifest Manifest, object StructuredDocuments) FetchManifestWithD return ParseRestManifest(json, packageId, version, channel); } - public static string UpdateRest(HttpClient client, SourceRecord source, string? appRoot = null) + public static string UpdateRest(HttpClient client, SourceRecord source, string? appRoot = null, bool identifierKeyed = false) { - var info = LoadInformation(client, source, appRoot); + var info = LoadInformation(client, source, appRoot, identifierKeyed); source.SourceVersion = info.ServerSupportedVersions.FirstOrDefault(); return $"REST source up to date (contract: {source.SourceVersion})"; } diff --git a/dotnet/src/Devolutions.Pinget.Core/SourceStore.cs b/dotnet/src/Devolutions.Pinget.Core/SourceStore.cs index baf8931..fda4b6b 100644 --- a/dotnet/src/Devolutions.Pinget.Core/SourceStore.cs +++ b/dotnet/src/Devolutions.Pinget.Core/SourceStore.cs @@ -33,6 +33,13 @@ internal record SourceStore }; } +internal enum EffectiveSourceMode +{ + Private, + SystemWingetDirect, + SystemWingetMirror +} + [JsonSerializable(typeof(SourceStore))] [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, WriteIndented = true)] internal partial class SourceStoreContext : JsonSerializerContext; @@ -43,6 +50,7 @@ internal static class SourceStoreManager internal const string PackagedName = "Microsoft.DesktopAppInstaller"; private const string LegacyStoreFileName = "sources.json"; + private const string SystemWingetMirrorStoreFileName = "system-sources.json"; private const string PackagedSourcesFileName = "user_sources"; private const string PackagedMetadataFileName = "sources_metadata"; private const string LegacyPinsFileName = "pins.db"; @@ -99,7 +107,7 @@ public static void EnsureAppDirs(string? appRoot = null) } } - public static string SourceStateDir(SourceRecord source, string? appRoot = null) + public static string SourceStateDir(SourceRecord source, string? appRoot = null, bool identifierKeyed = false) { var root = NormalizeAppRoot(appRoot); if (UsesPackagedLayout(root)) @@ -110,16 +118,19 @@ public static string SourceStateDir(SourceRecord source, string? appRoot = null) SanitizePathSegment(source.Identifier)); } + if (identifierKeyed) + return Path.Combine(root, "sources", SanitizePathSegment(source.Identifier)); + return Path.Combine(root, "sources", SanitizePathSegment(source.Name)); } - public static string SourceInstalledDbPath(SourceRecord source, string? appRoot = null) + public static string SourceInstalledDbPath(SourceRecord source, string? appRoot = null, bool identifierKeyed = false) { var root = NormalizeAppRoot(appRoot); if (UsesPackagedLayout(root)) return Path.Combine(root, SanitizePathSegment(source.Identifier), "installed.db"); - return Path.Combine(SourceStateDir(source, root), "installed.db"); + return Path.Combine(SourceStateDir(source, root, identifierKeyed), "installed.db"); } public static string PinsDbPath(string? appRoot = null) @@ -146,6 +157,84 @@ public static SourceStore Load(string? appRoot = null) return JsonSerializer.Deserialize(json, SourceStoreContext.Default.SourceStore) ?? SourceStore.Default(); } + internal static (EffectiveSourceMode Mode, SourceStore Store) LoadEffective(string appRoot, SourceMode sourceMode) + { + if (UsesSystemWingetSourceCommands(appRoot)) + return (EffectiveSourceMode.SystemWingetDirect, SystemWingetSourceStore.Load()); + + return sourceMode switch + { + SourceMode.Private => (EffectiveSourceMode.Private, Load(appRoot)), + SourceMode.SystemWingetMirror => (EffectiveSourceMode.SystemWingetMirror, LoadOrRefreshSystemWingetMirrorStore(appRoot, forceRefresh: false)), + _ => TryLoadAutoSystemWingetMirror(appRoot), + }; + } + + private static (EffectiveSourceMode Mode, SourceStore Store) TryLoadAutoSystemWingetMirror(string appRoot) + { + try + { + return (EffectiveSourceMode.SystemWingetMirror, LoadOrRefreshSystemWingetMirrorStore(appRoot, forceRefresh: false)); + } + catch + { + return (EffectiveSourceMode.Private, Load(appRoot)); + } + } + + internal static SourceStore RefreshSystemWingetMirrorStore(string appRoot) + { + var prior = LoadSystemWingetMirrorStore(appRoot) ?? SourceStore.Default(); + var exported = SystemWingetSourceStore.Load(); + MergeSourceCacheMetadata(exported, prior); + SaveSystemWingetMirrorStore(appRoot, exported); + return exported; + } + + internal static void SaveSystemWingetMirrorStore(string appRoot, SourceStore store) + { + Directory.CreateDirectory(NormalizeAppRoot(appRoot)); + var json = JsonSerializer.Serialize(store, SourceStoreContext.Default.SourceStore); + File.WriteAllText(SystemWingetMirrorStorePath(appRoot), json); + } + + private static SourceStore LoadOrRefreshSystemWingetMirrorStore(string appRoot, bool forceRefresh) + { + if (!forceRefresh && LoadSystemWingetMirrorStore(appRoot) is { } cached) + return cached; + + return RefreshSystemWingetMirrorStore(appRoot); + } + + private static SourceStore? LoadSystemWingetMirrorStore(string appRoot) + { + var path = SystemWingetMirrorStorePath(appRoot); + if (!File.Exists(path)) + return null; + + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize(json, SourceStoreContext.Default.SourceStore) + ?? throw new InvalidOperationException("Failed to parse system WinGet mirror source store."); + } + + private static string SystemWingetMirrorStorePath(string appRoot) => + Path.Combine(NormalizeAppRoot(appRoot), SystemWingetMirrorStoreFileName); + + private static void MergeSourceCacheMetadata(SourceStore target, SourceStore prior) + { + var priorByIdentifier = prior.Sources.ToDictionary(source => source.Identifier, StringComparer.OrdinalIgnoreCase); + foreach (var source in target.Sources) + { + if (!priorByIdentifier.TryGetValue(source.Identifier, out var previous)) + continue; + + source.LastUpdate = previous.LastUpdate; + source.SourceVersion = previous.SourceVersion; + source.ETag = previous.ETag; + source.LastModified = previous.LastModified; + } + } + internal static bool UsesSystemWingetSourceCommands(string? appRoot) { if (!OperatingSystem.IsWindows()) diff --git a/rust/crates/pinget-core/src/lib.rs b/rust/crates/pinget-core/src/lib.rs index 51784f1..e02f0e7 100644 --- a/rust/crates/pinget-core/src/lib.rs +++ b/rust/crates/pinget-core/src/lib.rs @@ -49,6 +49,7 @@ const PREINDEXED_CANDIDATES: &[&str] = &["source2.msix", "source.msix"]; const DEFAULT_USER_AGENT: &str = "pinget-rs/0.1"; const DEFAULT_PREINDEXED_AUTO_UPDATE_MINUTES: i64 = 15; const PREINDEXED_REFRESH_RETRY_MINUTES: i64 = 5; +const SYSTEM_WINGET_MIRROR_STORE_FILE_NAME: &str = "system-sources.json"; #[cfg(windows)] const PACKAGED_FAMILY_NAME: &str = "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe"; #[cfg(windows)] @@ -159,16 +160,32 @@ impl Default for SourceStore { pub struct RepositoryOptions { pub app_root: PathBuf, pub user_agent: String, + pub source_mode: SourceMode, pub pre_indexed_source_auto_update_interval: Option, pub install_progress: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceMode { + Auto, + Private, + SystemWingetMirror, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EffectiveSourceMode { + Private, + SystemWingetDirect, + SystemWingetMirror, +} + impl RepositoryOptions { /// Creates library options that keep all persistent state under the supplied root. pub fn new(app_root: impl Into) -> Self { Self { app_root: app_root.into(), user_agent: DEFAULT_USER_AGENT.to_owned(), + source_mode: SourceMode::Private, pre_indexed_source_auto_update_interval: Some(Duration::minutes(DEFAULT_PREINDEXED_AUTO_UPDATE_MINUTES)), install_progress: None, } @@ -176,7 +193,12 @@ impl RepositoryOptions { /// Uses the default per-user app-data root that the CLI also uses. pub fn for_current_user() -> Result { - Ok(Self::new(default_app_root()?)) + let source_mode = if std::env::var_os("PINGET_APPROOT").is_some() { + SourceMode::Private + } else { + SourceMode::Auto + }; + Ok(Self::new(default_app_root()?).with_source_mode(source_mode)) } /// Overrides the HTTP user-agent sent to source endpoints. @@ -186,6 +208,12 @@ impl RepositoryOptions { self } + #[must_use] + pub fn with_source_mode(mut self, source_mode: SourceMode) -> Self { + self.source_mode = source_mode; + self + } + /// Overrides automatic freshness checks for existing pre-indexed source indexes. #[must_use] pub fn with_pre_indexed_source_auto_update_interval(mut self, interval: Option) -> Self { @@ -871,7 +899,7 @@ pub struct Repository { user_agent: String, client: Client, store: SourceStore, - use_system_winget_sources: bool, + source_mode: EffectiveSourceMode, install_progress: Option, pre_indexed_source_auto_update_interval: Option, preindexed_refresh_attempts: HashMap, @@ -935,8 +963,7 @@ impl Repository { /// Opens the repository with explicit hosting options for library consumers. pub fn open_with_options(options: RepositoryOptions) -> Result { ensure_app_dirs(&options.app_root)?; - let use_system_winget_sources = uses_system_winget_source_commands(&options.app_root); - let store = load_store(&options.app_root)?; + let (source_mode, store) = load_effective_source_store(&options.app_root, options.source_mode)?; let client = Client::builder() .user_agent(&options.user_agent) .timeout(StdDuration::from_secs(30 * 60)) @@ -947,13 +974,21 @@ impl Repository { user_agent: options.user_agent, client, store, - use_system_winget_sources, + source_mode, install_progress: options.install_progress, pre_indexed_source_auto_update_interval: options.pre_indexed_source_auto_update_interval, preindexed_refresh_attempts: HashMap::new(), }) } + fn uses_system_winget_sources(&self) -> bool { + self.source_mode != EffectiveSourceMode::Private + } + + fn mirrors_system_winget_sources(&self) -> bool { + self.source_mode == EffectiveSourceMode::SystemWingetMirror + } + fn emit_install_progress(&self, progress: InstallProgress) { if let Some(callback) = self.install_progress { callback(&progress); @@ -981,7 +1016,7 @@ impl Repository { explicit: bool, priority: i32, ) -> Result<()> { - if self.use_system_winget_sources { + if self.uses_system_winget_sources() { system_winget_add_source(name, arg, kind, &normalize_source_trust_level(trust_level)?, explicit)?; self.refresh_system_winget_sources()?; return Ok(()); @@ -1007,12 +1042,14 @@ impl Repository { etag: None, last_modified: None, }); - self.save_store()?; + if self.source_mode != EffectiveSourceMode::SystemWingetDirect { + self.save_store()?; + } Ok(()) } pub fn edit_source(&mut self, name: &str, explicit: Option, trust_level: Option<&str>) -> Result<()> { - if self.use_system_winget_sources { + if self.uses_system_winget_sources() { bail!("Editing system WinGet sources is not supported by winget source commands."); } @@ -1036,7 +1073,7 @@ impl Repository { } pub fn remove_source(&mut self, name: &str) -> Result<()> { - if self.use_system_winget_sources { + if self.uses_system_winget_sources() { system_winget_remove_source(name)?; self.refresh_system_winget_sources()?; return Ok(()); @@ -1059,7 +1096,7 @@ impl Repository { } pub fn reset_source(&mut self, name: &str) -> Result<()> { - if self.use_system_winget_sources { + if self.uses_system_winget_sources() { system_winget_reset_source(name)?; self.refresh_system_winget_sources()?; return Ok(()); @@ -1096,7 +1133,7 @@ impl Repository { } pub fn reset_sources(&mut self) -> Result<()> { - if self.use_system_winget_sources { + if self.uses_system_winget_sources() { system_winget_reset_sources()?; self.refresh_system_winget_sources()?; return Ok(()); @@ -1210,10 +1247,14 @@ impl Repository { } pub fn update_sources(&mut self, source_name: Option<&str>) -> Result> { - if self.use_system_winget_sources { + if self.source_mode == EffectiveSourceMode::SystemWingetDirect { return self.update_system_winget_sources(source_name); } + if self.mirrors_system_winget_sources() { + self.refresh_system_winget_sources()?; + } + let indexes = self.resolve_source_indexes(source_name)?; let mut results = Vec::new(); @@ -1441,7 +1482,7 @@ impl Repository { for source_index in source_indices { let source = self.source_clone(source_index); - let installed_db_path = source_installed_db_path(&self.app_root, &source); + let installed_db_path = self.source_installed_db_path(&source); if !installed_db_path.exists() { continue; } @@ -2034,11 +2075,46 @@ impl Repository { } fn save_store(&self) -> Result<()> { - save_store(&self.app_root, &self.store) + if self.mirrors_system_winget_sources() { + save_system_winget_mirror_store(&self.app_root, &self.store) + } else { + save_store(&self.app_root, &self.store) + } } fn source_state_dir(&self, source: &SourceRecord) -> PathBuf { - source_state_dir(&self.app_root, source) + source_state_dir_with_keying(&self.app_root, source, self.mirrors_system_winget_sources()) + } + + fn source_installed_db_path(&self, source: &SourceRecord) -> PathBuf { + source_installed_db_path_with_keying(&self.app_root, source, self.mirrors_system_winget_sources()) + } + + fn preindexed_package_path(&self, source: &SourceRecord) -> PathBuf { + self.source_state_dir(source).join("source.msix") + } + + fn preindexed_index_path(&self, source: &SourceRecord) -> PathBuf { + self.source_state_dir(source).join("index.db") + } + + fn rest_information_cache_path(&self, source: &SourceRecord) -> PathBuf { + self.source_state_dir(source).join("rest-information.json") + } + + fn rest_manifest_cache_path( + &self, + source: &SourceRecord, + package_id: &str, + version: &str, + channel: &str, + ) -> PathBuf { + let key = format!("{}|{}|{}|{}", source.identifier, package_id, version, channel); + let digest = sha256_hex(key.as_bytes()); + cache_root(&self.app_root) + .join("REST_M") + .join(sanitize_path_segment(&source.identifier)) + .join(format!("{digest}.json")) } // ── Install / download ── @@ -3064,7 +3140,7 @@ impl Repository { fn open_preindexed_connection_with_refresh(&mut self, source_index: usize) -> Result { let source = self.source_clone(source_index); - let index_path = preindexed_index_path(&self.app_root, &source); + let index_path = self.preindexed_index_path(&source); let mut refresh = PreindexedRefreshOutcome::default(); if !index_path.exists() { let _ = self.refresh_preindexed_source(source_index, true)?; @@ -3161,7 +3237,7 @@ impl Repository { fn refresh_preindexed_source(&mut self, source_index: usize, force: bool) -> Result { let source = self.source_clone(source_index); - let index_path = preindexed_index_path(&self.app_root, &source); + let index_path = self.preindexed_index_path(&source); let lock_key = self.preindexed_refresh_lock_key(&source); let refresh_lock = Self::preindexed_refresh_lock(&lock_key)?; let _guard = refresh_lock @@ -3175,9 +3251,7 @@ impl Repository { let result = self.update_preindexed(source_index); self.record_preindexed_refresh_attempt(lock_key, result.is_ok()); let detail = result?; - if !self.use_system_winget_sources { - self.save_store()?; - } + self.save_store()?; Ok(detail) } @@ -3192,9 +3266,9 @@ impl Repository { fn update_preindexed(&mut self, source_index: usize) -> Result { let source_snapshot = self.store.sources[source_index].clone(); - let state_dir = source_state_dir(&self.app_root, &source_snapshot); + let state_dir = self.source_state_dir(&source_snapshot); fs::create_dir_all(&state_dir).context("failed to create source state directory")?; - let index_path = preindexed_index_path(&self.app_root, &source_snapshot); + let index_path = self.preindexed_index_path(&source_snapshot); let has_local_index = index_path.exists(); let mut last_error = None; @@ -3240,7 +3314,7 @@ impl Repository { let index_bytes = extract_zip_member(&payload, "Public/index.db") .context("preindexed package did not contain Public/index.db")?; - fs::write(preindexed_package_path(&self.app_root, &source_snapshot), &payload) + fs::write(self.preindexed_package_path(&source_snapshot), &payload) .context("failed to persist source package")?; let temp_index_path = state_dir.join(format!( "index.{}.tmp", @@ -3313,7 +3387,7 @@ impl Repository { fn load_rest_information(&mut self, source_index: usize) -> Result { let source = self.source_clone(source_index); - let cache_path = rest_information_cache_path(&self.app_root, &source); + let cache_path = self.rest_information_cache_path(&source); if cache_path.exists() { let cache = serde_json::from_slice::( @@ -3355,7 +3429,7 @@ impl Repository { expires_at: Utc::now() + Duration::seconds(i64::try_from(max_age).unwrap_or(i64::MAX)), value: info.clone(), }; - write_json(rest_information_cache_path(&self.app_root, &source), &cache)?; + write_json(self.rest_information_cache_path(&source), &cache)?; } Ok(info) @@ -3367,7 +3441,7 @@ impl Repository { package_rowid: i64, package_hash: &str, ) -> Result<(Vec, PathBuf)> { - let connection = Self::open_sqlite_connection(preindexed_index_path(&self.app_root, source)) + let connection = Self::open_sqlite_connection(self.preindexed_index_path(source)) .context("failed to reopen preindexed index for V2 version data")?; let package_hash = package_hash.to_ascii_lowercase(); let package_id = query_optional_value( @@ -3442,7 +3516,7 @@ impl Repository { version: &str, channel: &str, ) -> Result<(Vec, PathBuf)> { - let cache_path = rest_manifest_cache_path_with_root(&self.app_root, source, package_id, version, channel); + let cache_path = self.rest_manifest_cache_path(source, package_id, version, channel); if cache_path.exists() { return Ok(( fs::read(&cache_path).context("failed to read cached REST manifest")?, @@ -3531,9 +3605,11 @@ impl Repository { } fn refresh_system_winget_sources(&mut self) -> Result<()> { - if self.use_system_winget_sources { - self.store = load_system_winget_source_store()?; - } + self.store = match self.source_mode { + EffectiveSourceMode::Private => self.store.clone(), + EffectiveSourceMode::SystemWingetDirect => load_system_winget_source_store()?, + EffectiveSourceMode::SystemWingetMirror => refresh_system_winget_mirror_store(&self.app_root)?, + }; Ok(()) } @@ -4969,30 +5045,41 @@ fn admin_settings_path(app_root: &Path) -> PathBuf { app_root.join("admin-settings.json") } +#[cfg(test)] fn source_state_dir(app_root: &Path, source: &SourceRecord) -> PathBuf { + source_state_dir_with_keying(app_root, source, false) +} + +fn source_state_dir_with_keying(app_root: &Path, source: &SourceRecord, identifier_keyed: bool) -> PathBuf { if uses_packaged_layout(app_root) { return app_root .join(packaged_source_type(source.kind)) .join(sanitize_path_segment(&source.identifier)); } + if identifier_keyed { + return app_root.join("sources").join(sanitize_path_segment(&source.identifier)); + } + app_root.join("sources").join(sanitize_path_segment(&source.name)) } +#[cfg(test)] fn source_installed_db_path(app_root: &Path, source: &SourceRecord) -> PathBuf { + source_installed_db_path_with_keying(app_root, source, false) +} + +fn source_installed_db_path_with_keying(app_root: &Path, source: &SourceRecord, identifier_keyed: bool) -> PathBuf { if uses_packaged_layout(app_root) { return app_root .join(sanitize_path_segment(&source.identifier)) .join("installed.db"); } - source_state_dir(app_root, source).join("installed.db") -} - -fn preindexed_package_path(app_root: &Path, source: &SourceRecord) -> PathBuf { - source_state_dir(app_root, source).join("source.msix") + source_state_dir_with_keying(app_root, source, identifier_keyed).join("installed.db") } +#[cfg(test)] fn preindexed_index_path(app_root: &Path, source: &SourceRecord) -> PathBuf { source_state_dir(app_root, source).join("index.db") } @@ -5105,25 +5192,6 @@ fn replace_file(source: &Path, target: &Path) -> Result<()> { .with_context(|| format!("failed to replace {} with {}", target.display(), source.display())) } -fn rest_information_cache_path(app_root: &Path, source: &SourceRecord) -> PathBuf { - source_state_dir(app_root, source).join("rest-information.json") -} - -fn rest_manifest_cache_path_with_root( - app_root: &Path, - source: &SourceRecord, - package_id: &str, - version: &str, - channel: &str, -) -> PathBuf { - let key = format!("{}|{}|{}|{}", source.identifier, package_id, version, channel); - let digest = sha256_hex(key.as_bytes()); - cache_root(app_root) - .join("REST_M") - .join(sanitize_path_segment(&source.identifier)) - .join(format!("{digest}.json")) -} - fn temp_cache_path(app_root: &Path, bucket: &str, identifier: &str) -> PathBuf { cache_root(app_root) .join(bucket) @@ -5148,6 +5216,81 @@ fn load_store(app_root: &Path) -> Result { serde_json::from_slice(&bytes).context("failed to parse source store") } +fn load_effective_source_store(app_root: &Path, source_mode: SourceMode) -> Result<(EffectiveSourceMode, SourceStore)> { + if uses_system_winget_source_commands(app_root) { + return Ok(( + EffectiveSourceMode::SystemWingetDirect, + load_system_winget_source_store()?, + )); + } + + match source_mode { + SourceMode::Private => Ok((EffectiveSourceMode::Private, load_store(app_root)?)), + SourceMode::SystemWingetMirror => { + let store = load_or_refresh_system_winget_mirror_store(app_root, false)?; + Ok((EffectiveSourceMode::SystemWingetMirror, store)) + } + SourceMode::Auto => match load_or_refresh_system_winget_mirror_store(app_root, false) { + Ok(store) => Ok((EffectiveSourceMode::SystemWingetMirror, store)), + Err(_) => Ok((EffectiveSourceMode::Private, load_store(app_root)?)), + }, + } +} + +fn system_winget_mirror_store_path(app_root: &Path) -> PathBuf { + app_root.join(SYSTEM_WINGET_MIRROR_STORE_FILE_NAME) +} + +fn load_system_winget_mirror_store(app_root: &Path) -> Result> { + let path = system_winget_mirror_store_path(app_root); + if !path.exists() { + return Ok(None); + } + + let bytes = fs::read(path).context("failed to read system WinGet mirror source store")?; + serde_json::from_slice(&bytes) + .context("failed to parse system WinGet mirror source store") + .map(Some) +} + +fn save_system_winget_mirror_store(app_root: &Path, store: &SourceStore) -> Result<()> { + write_json(system_winget_mirror_store_path(app_root), store) +} + +fn load_or_refresh_system_winget_mirror_store(app_root: &Path, force_refresh: bool) -> Result { + if !force_refresh && let Some(store) = load_system_winget_mirror_store(app_root)? { + return Ok(store); + } + + refresh_system_winget_mirror_store(app_root) +} + +fn refresh_system_winget_mirror_store(app_root: &Path) -> Result { + let prior = load_system_winget_mirror_store(app_root)?.unwrap_or_default(); + let mut exported = load_system_winget_source_store()?; + merge_source_cache_metadata(&mut exported, &prior); + save_system_winget_mirror_store(app_root, &exported)?; + Ok(exported) +} + +fn merge_source_cache_metadata(target: &mut SourceStore, prior: &SourceStore) { + let prior_by_identifier: HashMap<_, _> = prior + .sources + .iter() + .map(|source| (source.identifier.to_ascii_lowercase(), source)) + .collect(); + + for source in &mut target.sources { + let Some(previous) = prior_by_identifier.get(&source.identifier.to_ascii_lowercase()) else { + continue; + }; + source.last_update = previous.last_update; + source.source_version = previous.source_version.clone(); + source.etag = previous.etag.clone(); + source.last_modified = previous.last_modified.clone(); + } +} + fn save_store(app_root: &Path, store: &SourceStore) -> Result<()> { if uses_system_winget_source_commands(app_root) { bail!("System WinGet source settings must be modified through winget."); @@ -11136,6 +11279,66 @@ mod tests { assert_eq!(source.kind, SourceKind::Rest); } + #[test] + fn system_winget_mirror_store_uses_private_cache_and_preserves_metadata() { + let original_runner = { + let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock"); + let original = *runner; + *runner = fake_system_winget_source_export; + original + }; + + let app_root = temp_app_root("system-winget-mirror"); + let result = load_effective_source_store(&app_root, SourceMode::SystemWingetMirror); + + { + let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock"); + *runner = original_runner; + } + + let (mode, mut store) = result.expect("mirror store"); + assert_eq!(mode, EffectiveSourceMode::SystemWingetMirror); + assert!(system_winget_mirror_store_path(&app_root).exists()); + assert!( + !store_path(&app_root).exists(), + "mirror mode must not write private sources.json" + ); + + let source = store.sources.first_mut().expect("source"); + source.last_update = Some(Utc::now()); + source.source_version = Some("cached-contract".to_owned()); + source.etag = Some("\"etag\"".to_owned()); + source.last_modified = Some("Wed, 03 Jun 2026 12:00:00 GMT".to_owned()); + save_system_winget_mirror_store(&app_root, &store).expect("save mirror"); + + let original_runner = { + let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock"); + let original = *runner; + *runner = fake_system_winget_source_export; + original + }; + let refreshed = refresh_system_winget_mirror_store(&app_root); + { + let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock"); + *runner = original_runner; + } + + let refreshed = refreshed.expect("refresh mirror"); + let source = refreshed.sources.first().expect("source"); + assert_eq!(source.source_version.as_deref(), Some("cached-contract")); + assert_eq!(source.etag.as_deref(), Some("\"etag\"")); + assert_eq!( + source_state_dir_with_keying(&app_root, source, true), + app_root.join("sources").join("api.contoso.test") + ); + assert_eq!( + source_state_dir_with_keying(&app_root, source, false), + app_root.join("sources").join("contoso") + ); + + let _ = fs::remove_dir_all(&app_root); + } + #[test] fn source_metadata_and_named_reset_round_trip() { let app_root = temp_app_root("source-reset"); From ffc17ef0ce3ea11a0e4cc69d4106dd769db5fc2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Mon, 8 Jun 2026 10:09:45 -0400 Subject: [PATCH 5/6] Fix show metadata for mirrored WinGet sources Allow show metadata lookups that receive the WinGet manager alias to retry across the mirrored system source set when the default winget source has no match. Also allow source resolution by source identifier so custom REST identifiers work consistently. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CoreTests.cs | 25 +++ .../src/Devolutions.Pinget.Core/Repository.cs | 60 +++++- rust/crates/pinget-core/src/lib.rs | 181 +++++++++++++++--- 3 files changed, 229 insertions(+), 37 deletions(-) diff --git a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs index 42c9b3a..c32f093 100644 --- a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs +++ b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs @@ -152,6 +152,31 @@ public void DefaultAppRootOutsidePackage_AvoidsPackagedLayout() Assert.DoesNotContain(Path.Combine("Packages", SourceStoreManager.PackagedFamilyName), appRoot, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void ShowMetadata_RetriesWingetManagerAliasOnlyForCleanMisses() + { + Assert.True(Repository.ShouldRetryShowAcrossSystemWingetSources( + usesSystemWingetSources: true, + source: "winget", + matchCount: 0, + failureCount: 0)); + Assert.False(Repository.ShouldRetryShowAcrossSystemWingetSources( + usesSystemWingetSources: false, + source: "winget", + matchCount: 0, + failureCount: 0)); + Assert.False(Repository.ShouldRetryShowAcrossSystemWingetSources( + usesSystemWingetSources: true, + source: "winget.pro", + matchCount: 0, + failureCount: 0)); + Assert.False(Repository.ShouldRetryShowAcrossSystemWingetSources( + usesSystemWingetSources: true, + source: "winget", + matchCount: 0, + failureCount: 1)); + } + [Fact] public void PackagedSourceYaml_OverlaysDefaultsAndMetadata() { diff --git a/dotnet/src/Devolutions.Pinget.Core/Repository.cs b/dotnet/src/Devolutions.Pinget.Core/Repository.cs index 513fb16..e14e503 100644 --- a/dotnet/src/Devolutions.Pinget.Core/Repository.cs +++ b/dotnet/src/Devolutions.Pinget.Core/Repository.cs @@ -425,7 +425,7 @@ public SearchResponse Search(PackageQuery query) public ShowResult Show(PackageQuery query) { - var (located, warnings, sourceWarnings) = FindSingleMatch(query); + var (located, warnings, sourceWarnings) = FindSingleMatchForShow(query, SearchSemantics.Single); return CreateShowResult(located, query, warnings, sourceWarnings); } @@ -491,7 +491,7 @@ private ShowResult CreateShowResult( public VersionsResult ShowVersions(PackageQuery query) { - var (located, warnings, sourceWarnings) = FindSingleMatch(query); + var (located, warnings, sourceWarnings) = FindSingleMatchForShow(query, SearchSemantics.Single); var versions = VersionsForMatch(located, query); return new VersionsResult { Package = located.Display, Versions = versions, Warnings = warnings, SourceWarnings = sourceWarnings }; } @@ -1757,23 +1757,58 @@ private static MissingPackageVersionException WithRefreshFailure(MissingPackageV private (LocatedMatch Match, List Warnings, List SourceWarnings) FindSingleMatch(PackageQuery query) => FindSingleMatchWithSemantics(query, SearchSemantics.Single); + private (LocatedMatch Match, List Warnings, List SourceWarnings) FindSingleMatchForShow( + PackageQuery query, + SearchSemantics semantics) + { + var located = SearchLocated(query, semantics); + if (ShouldRetryShowAcrossSystemWingetSources(query, located.Matches, located.Failures)) + { + var fallbackQuery = query with { Source = null }; + return SingleMatchFromLocated(SearchLocated(fallbackQuery, semantics), reportFirstSourceFailure: false); + } + + return SingleMatchFromLocated(located, reportFirstSourceFailure: query.Source is not null); + } + + private bool ShouldRetryShowAcrossSystemWingetSources( + PackageQuery query, + IReadOnlyCollection matches, + IReadOnlyCollection failures) => + ShouldRetryShowAcrossSystemWingetSources(UsesSystemWingetSources, query.Source, matches.Count, failures.Count); + + internal static bool ShouldRetryShowAcrossSystemWingetSources( + bool usesSystemWingetSources, + string? source, + int matchCount, + int failureCount) => + usesSystemWingetSources && + string.Equals(source, "winget", StringComparison.OrdinalIgnoreCase) && + matchCount == 0 && + failureCount == 0; + private (LocatedMatch Match, List Warnings, List SourceWarnings) FindSingleMatchWithSemantics( PackageQuery query, SearchSemantics semantics) { - var (matches, warnings, failures, _) = SearchLocated(query, semantics); + return SingleMatchFromLocated(SearchLocated(query, semantics), reportFirstSourceFailure: query.Source is not null); + } - if (matches.Count == 0) + private static (LocatedMatch Match, List Warnings, List SourceWarnings) SingleMatchFromLocated( + (List Matches, List Warnings, List Failures, bool Truncated) located, + bool reportFirstSourceFailure) + { + if (located.Matches.Count == 0) { - if (query.Source is not null && failures.Count > 0) - throw new SourceSearchException(failures[0].Warning, failures[0].Exception); + if (reportFirstSourceFailure && located.Failures.Count > 0) + throw new SourceSearchException(located.Failures[0].Warning, located.Failures[0].Exception); throw new InvalidOperationException("no package matched the supplied query"); } - if (matches.Count > 1) - throw new MultiplePackageMatchesException(matches.Select(m => m.Display)); + if (located.Matches.Count > 1) + throw new MultiplePackageMatchesException(located.Matches.Select(m => m.Display)); - return (matches[0], warnings, failures.Select(f => f.Warning).ToList()); + return (located.Matches[0], located.Warnings, located.Failures.Select(f => f.Warning).ToList()); } private List VersionsForMatch(LocatedMatch located, PackageQuery query) @@ -4011,6 +4046,13 @@ private List ResolveSourceIndexes(string? sourceName) if (_store.Sources[i].Name.Equals(sourceName, StringComparison.OrdinalIgnoreCase)) return [i]; } + + for (int i = 0; i < _store.Sources.Count; i++) + { + if (_store.Sources[i].Identifier.Equals(sourceName, StringComparison.OrdinalIgnoreCase)) + return [i]; + } + throw new InvalidOperationException($"Source '{sourceName}' not found."); } diff --git a/rust/crates/pinget-core/src/lib.rs b/rust/crates/pinget-core/src/lib.rs index e02f0e7..f175b0d 100644 --- a/rust/crates/pinget-core/src/lib.rs +++ b/rust/crates/pinget-core/src/lib.rs @@ -827,6 +827,50 @@ struct SearchLocatedResult { truncated: bool, } +fn single_match_from_located( + located: SearchLocatedResult, + report_first_source_failure: bool, +) -> Result<(LocatedMatch, Vec)> { + if located.matches.is_empty() { + if report_first_source_failure && let Some(failure) = located.failures.first() { + bail!("failed to search source '{}': {}", failure.source_name, failure.message); + } + bail!("no package matched the supplied query"); + } + + if located.matches.len() > 1 { + let choices = located + .matches + .iter() + .take(10) + .map(|item| { + format!( + "{} [{}] ({})", + item.display.name, item.display.id, item.display.source_name + ) + }) + .collect::>() + .join(", "); + bail!("multiple packages matched: {choices}"); + } + + Ok((located.matches.into_iter().next().expect("one match"), located.warnings)) +} + +fn should_retry_show_across_system_winget_sources( + uses_system_winget_sources: bool, + query: &PackageQuery, + located: &SearchLocatedResult, +) -> bool { + uses_system_winget_sources + && query + .source + .as_deref() + .is_some_and(|source| source.eq_ignore_ascii_case("winget")) + && located.matches.is_empty() + && located.failures.is_empty() +} + #[derive(Debug, Clone)] enum MatchLocator { PreIndexedV1 { @@ -1925,7 +1969,7 @@ impl Repository { } pub fn show_versions(&mut self, query: &PackageQuery) -> Result { - let (located, warnings) = self.find_single_match(query)?; + let (located, warnings) = self.find_single_match_for_show(query, SearchSemantics::Single)?; let versions = self.versions_for_match(&located, query)?; Ok(VersionsResult { package: located.display, @@ -1935,7 +1979,7 @@ impl Repository { } pub fn show(&mut self, query: &PackageQuery) -> Result { - let (located, warnings) = self.find_single_match(query)?; + let (located, warnings) = self.find_single_match_for_show(query, SearchSemantics::Single)?; let (manifest, manifest_documents, cached_files) = self.manifest_for_match(&located, query)?; let selected_installer = select_installer(&manifest.installers, query); @@ -2693,39 +2737,36 @@ impl Repository { self.find_single_match_with_semantics(query, SearchSemantics::Single) } - fn find_single_match_with_semantics( + fn find_single_match_for_show( &mut self, query: &PackageQuery, semantics: SearchSemantics, ) -> Result<(LocatedMatch, Vec)> { let located = self.search_located(query, semantics)?; - - if located.matches.is_empty() { - if query.source.is_some() - && let Some(failure) = located.failures.first() - { - bail!("failed to search source '{}': {}", failure.source_name, failure.message); - } - bail!("no package matched the supplied query"); + if self.should_retry_show_across_system_winget_sources(query, &located) { + let mut fallback_query = query.clone(); + fallback_query.source = None; + return single_match_from_located(self.search_located(&fallback_query, semantics)?, false); } - if located.matches.len() > 1 { - let choices = located - .matches - .iter() - .take(10) - .map(|item| { - format!( - "{} [{}] ({})", - item.display.name, item.display.id, item.display.source_name - ) - }) - .collect::>() - .join(", "); - bail!("multiple packages matched: {choices}"); - } + single_match_from_located(located, query.source.is_some()) + } - Ok((located.matches.into_iter().next().expect("one match"), located.warnings)) + fn should_retry_show_across_system_winget_sources( + &self, + query: &PackageQuery, + located: &SearchLocatedResult, + ) -> bool { + should_retry_show_across_system_winget_sources(self.uses_system_winget_sources(), query, located) + } + + fn find_single_match_with_semantics( + &mut self, + query: &PackageQuery, + semantics: SearchSemantics, + ) -> Result<(LocatedMatch, Vec)> { + let located = self.search_located(query, semantics)?; + single_match_from_located(located, query.source.is_some()) } fn search_located(&mut self, query: &PackageQuery, semantics: SearchSemantics) -> Result { @@ -3575,11 +3616,20 @@ impl Repository { fn resolve_source_indexes(&self, source_name: Option<&str>) -> Result> { if let Some(name) = source_name { - let index = self + if let Some(index) = self .store .sources .iter() .position(|source| source.name.eq_ignore_ascii_case(name)) + { + return Ok(vec![index]); + } + + let index = self + .store + .sources + .iter() + .position(|source| source.identifier.eq_ignore_ascii_case(name)) .ok_or_else(|| anyhow!("source '{name}' was not configured"))?; return Ok(vec![index]); } @@ -11408,6 +11458,81 @@ mod tests { result.expect("source resolution"); } + #[test] + fn source_resolution_accepts_source_identifier() { + let app_root = temp_app_root("source-identifier-resolution"); + let result = (|| -> Result<()> { + let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?; + repository.add_source_with_metadata( + "winget.pro", + "https://api.winget.pro/feed", + SourceKind::Rest, + Some("trusted"), + false, + 0, + )?; + repository.store.sources[0].identifier = "api.winget.pro".to_owned(); + + let indexes = repository.resolve_source_indexes(Some("api.winget.pro"))?; + assert_eq!(indexes, vec![0]); + Ok(()) + })(); + + let _ = fs::remove_dir_all(&app_root); + result.expect("source identifier resolution"); + } + + #[test] + fn show_metadata_retries_winget_manager_alias_only_for_clean_misses() { + let query = PackageQuery { + id: Some("tessl.tessl".to_owned()), + source: Some("winget".to_owned()), + exact: true, + ..PackageQuery::default() + }; + let clean_miss = SearchLocatedResult { + matches: Vec::new(), + warnings: Vec::new(), + failures: Vec::new(), + truncated: false, + }; + assert!(should_retry_show_across_system_winget_sources( + true, + &query, + &clean_miss + )); + + let private_mode = false; + assert!(!should_retry_show_across_system_winget_sources( + private_mode, + &query, + &clean_miss + )); + + let explicit_custom_source = PackageQuery { + source: Some("winget.pro".to_owned()), + ..query.clone() + }; + assert!(!should_retry_show_across_system_winget_sources( + true, + &explicit_custom_source, + &clean_miss + )); + + let failed_source = SearchLocatedResult { + failures: vec![SourceSearchFailure { + source_name: "winget".to_owned(), + message: "network failure".to_owned(), + }], + ..clean_miss + }; + assert!(!should_retry_show_across_system_winget_sources( + true, + &query, + &failed_source + )); + } + #[test] fn user_and_admin_settings_round_trip() { let app_root = temp_app_root("settings"); From ec564c2acbbbcdd4c3c4b2ef4e8f2b8a266a01af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Mon, 8 Jun 2026 10:19:58 -0400 Subject: [PATCH 6/6] Fix Rust lint on non-Windows targets Remove a Windows-test-only source installed DB path wrapper that was dead code on Linux clippy runs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/crates/pinget-core/src/lib.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/rust/crates/pinget-core/src/lib.rs b/rust/crates/pinget-core/src/lib.rs index f175b0d..3727a65 100644 --- a/rust/crates/pinget-core/src/lib.rs +++ b/rust/crates/pinget-core/src/lib.rs @@ -5114,11 +5114,6 @@ fn source_state_dir_with_keying(app_root: &Path, source: &SourceRecord, identifi app_root.join("sources").join(sanitize_path_segment(&source.name)) } -#[cfg(test)] -fn source_installed_db_path(app_root: &Path, source: &SourceRecord) -> PathBuf { - source_installed_db_path_with_keying(app_root, source, false) -} - fn source_installed_db_path_with_keying(app_root: &Path, source: &SourceRecord, identifier_keyed: bool) -> PathBuf { if uses_packaged_layout(app_root) { return app_root @@ -11141,7 +11136,7 @@ mod tests { )) ); assert!( - source_installed_db_path(&app_root, &source) + source_installed_db_path_with_keying(&app_root, &source, false) .display() .to_string() .ends_with(&format!(