Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/parity-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,12 @@ jobs:

- name: Run Windows parity coherence tests
shell: pwsh
# Point pinget at winget's packaged LocalState so pin/source/settings
# stores are shared with the system winget. Without this, non-packaged
# pinget reads %LocalAppData%\Devolutions\Pinget\ and the cross-CLI
# coherence assertions cannot see each other's state.
run: |
$env:PINGET_APPROOT = Join-Path $env:LOCALAPPDATA 'Packages\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\LocalState'
$rustPinget = Resolve-Path 'rust/target/debug/pinget.exe'
$dotnetPinget = Resolve-Path 'dotnet/src/Devolutions.Pinget.Cli/bin/Release/net10.0/pinget.exe'
$pingetModule = Resolve-Path 'dist/powershell-module/Devolutions.Pinget.Client/Devolutions.Pinget.Client.psd1'
Expand Down
65 changes: 65 additions & 0 deletions dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1764,6 +1764,71 @@ public void UpgradeFilter_HidesRequireExplicitUpgrade_ByDefault()
Assert.True(Repository.InstalledPackageMatchesUpgradeFilterForTesting(pkg, bulkQuery));
}

[Fact]
public void UpgradeFilter_HidesLacksCompatibleInstaller_ByDefault()
{
var pkg = new InstalledPackage
{
Name = "Foo",
LocalId = @"ARP\Machine\X64\Foo",
InstalledVersion = "1.0",
Scope = "Machine",
InstallerCategory = "exe",
Correlated = new SearchMatch
{
SourceName = "winget",
SourceKind = SourceKind.PreIndexed,
Id = "Test.Foo",
Name = "Foo",
Version = "2.0",
},
CorrelatedLacksCompatibleInstaller = true,
};

var bulkQuery = new ListQuery { UpgradeOnly = true };
Assert.False(Repository.InstalledPackageMatchesUpgradeFilterForTesting(pkg, bulkQuery));

var filteredQuery = new ListQuery { UpgradeOnly = true, Id = "Test.Foo" };
Assert.True(Repository.InstalledPackageMatchesUpgradeFilterForTesting(pkg, filteredQuery));

pkg.CorrelatedLacksCompatibleInstaller = false;
Assert.True(Repository.InstalledPackageMatchesUpgradeFilterForTesting(pkg, bulkQuery));
}

private static Manifest SyntheticManifestWithInstallerArches(params string[] arches)
{
var installers = arches.Select(a => new Installer { Architecture = a }).ToList();
return new Manifest { Id = "Test", Name = "Test", Version = "1.0", Installers = installers };
}

[Fact]
public void ManifestHasCompatibleInstaller_NeutralAlwaysMatches()
{
Assert.True(Repository.ManifestHasCompatibleInstallerForTesting(
SyntheticManifestWithInstallerArches("neutral")));
}

[Fact]
public void ManifestHasCompatibleInstaller_EmptyInstallersPassThrough()
{
Assert.True(Repository.ManifestHasCompatibleInstallerForTesting(
SyntheticManifestWithInstallerArches()));
}

[Fact]
public void ManifestHasCompatibleInstaller_RejectsAlienArchOnly()
{
Assert.False(Repository.ManifestHasCompatibleInstallerForTesting(
SyntheticManifestWithInstallerArches("ppc")));
}

[Fact]
public void ManifestHasCompatibleInstaller_MixedSetPassesIfAnyMatch()
{
Assert.True(Repository.ManifestHasCompatibleInstallerForTesting(
SyntheticManifestWithInstallerArches("ppc", "neutral")));
}

[Fact]
public void ApplyMsixResourceStringNameFix_ResolvesPlaceholderToCatalogName()
{
Expand Down
3 changes: 3 additions & 0 deletions dotnet/src/Devolutions.Pinget.Core/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,9 @@ internal record InstalledPackage
// RequireExplicitUpgrade: true. winget hides those rows from bulk
// `upgrade`; we mirror that. Users can still upgrade by explicit id.
public bool CorrelatedRequiresExplicitUpgrade { get; set; }
// True when the correlated catalog package's latest version has no
// installer for an architecture the user can actually run.
public bool CorrelatedLacksCompatibleInstaller { get; set; }
}

internal enum SearchSemantics
Expand Down
164 changes: 93 additions & 71 deletions dotnet/src/Devolutions.Pinget.Core/NameNormalization.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ namespace Devolutions.Pinget.Core;
/// suffixes before comparing. This class reproduces those transformations
/// so we can correlate the same set of ARP rows winget does.
/// </summary>
internal static class NameNormalization
internal static partial class NameNormalization
{
internal enum Architecture
{
Expand All @@ -38,7 +38,7 @@ public static NormalizedName NormalizeName(string value)

// SAP Business Object program names follow a specific pattern that
// breaks under the regular flow; winget short-circuits them.
if (SapPackage.IsMatch(name))
if (SapPackage().IsMatch(name))
{
return new NormalizedName(name, Architecture.Unknown, string.Empty);
}
Expand All @@ -49,13 +49,13 @@ public static NormalizedName NormalizeName(string value)
// Preserve KB numbers from within parens before the bracket strippers
// would eat them — winget keeps `KB1234567` as part of the normalized
// name because it's the only meaningful identifier on some patches.
name = KbNumbers.Replace(name, "$1");
name = KbNumbers().Replace(name, "$1");

while (RemoveAll(ProgramNameRegexes, ref name)) { }

var tokens = SplitWithLegalSuffixExclusion(ProgramNameSplit, name, stopOnExclusion: false);
var tokens = SplitWithLegalSuffixExclusion(ProgramNameSplit(), name, stopOnExclusion: false);
name = string.Concat(tokens);
name = NonLettersAndDigits.Replace(name, string.Empty);
name = NonLettersAndDigits().Replace(name, string.Empty);

return new NormalizedName(name.ToLowerInvariant(), architecture, locale.ToLowerInvariant());
}
Expand All @@ -77,9 +77,9 @@ public static string NormalizePublisher(string value)
// Publisher split stops at the FIRST legal-entity suffix it sees
// (after the first token), so "Foo Inc Internal Sub Bar" keeps just
// "Foo" — "Inc" cuts off everything beyond.
var tokens = SplitWithLegalSuffixExclusion(PublisherNameSplit, publisher, stopOnExclusion: true);
var tokens = SplitWithLegalSuffixExclusion(PublisherNameSplit(), publisher, stopOnExclusion: true);
publisher = string.Concat(tokens);
publisher = NonLettersAndDigits.Replace(publisher, string.Empty);
publisher = NonLettersAndDigits().Replace(publisher, string.Empty);
return publisher.ToLowerInvariant();
}

Expand Down Expand Up @@ -137,18 +137,18 @@ private static Architecture RemoveArchitecture(ref string value)
{
// Order matters: "32/64-bit" is a superstring of "64-bit"; "X64"/
// "AMD64" must beat "X32"/"X86" because of "x86-64".
if (Remove(Architecture32Or64Bit, ref value))
if (Remove(Architecture32Or64Bit(), ref value))
return Architecture.Unknown;
if (Remove(ArchitectureX64, ref value) || Remove(Architecture64Bit, ref value))
if (Remove(ArchitectureX64(), ref value) || Remove(Architecture64Bit(), ref value))
return Architecture.X64;
if (Remove(ArchitectureX32, ref value) || Remove(Architecture32Bit, ref value))
if (Remove(ArchitectureX32(), ref value) || Remove(Architecture32Bit(), ref value))
return Architecture.X86;
return Architecture.Unknown;
}

private static string RemoveLocale(ref string value)
{
var matches = Locale.Matches(value);
var matches = Locale().Matches(value);
if (matches.Count == 0) return string.Empty;

var newValue = new System.Text.StringBuilder(value.Length);
Expand Down Expand Up @@ -213,72 +213,94 @@ bool PushSegment(string segment)
return result;
}

// ── Regex patterns. .NET regex supports variable-length lookbehind
// directly, so the C++ patterns port over unchanged. ─────────────────

private const RegexOptions Opts = RegexOptions.IgnoreCase | RegexOptions.Compiled;

private static readonly Regex ArchitectureX32 =
new(@"(?<=^|[^\p{L}\p{Nd}])(X32|X86)(?=\P{Nd}|$)(?:\sEDITION)?", Opts);
private static readonly Regex ArchitectureX64 =
new(@"(?<=^|[^\p{L}\p{Nd}])(X64|AMD64|X86([\p{Pd}\p{Pc}]64))(?=\P{Nd}|$)(?:\sEDITION)?", Opts);
private static readonly Regex Architecture32Bit =
new(@"(?<=^|[^\p{L}\p{Nd}])(32[\p{Pd}\p{Pc}\p{Z}]?BIT)S?(?:\sEDITION)?", Opts);
private static readonly Regex Architecture64Bit =
new(@"(?<=^|[^\p{L}\p{Nd}])(64[\p{Pd}\p{Pc}\p{Z}]?BIT)S?(?:\sEDITION)?", Opts);
private static readonly Regex Architecture32Or64Bit =
new(@"(?<=^|[^\p{L}\p{Nd}])((64[\\/]32|32[\\/]64)[\p{Pd}\p{Pc}\p{Z}]?BIT)S?(?:\sEDITION)?", Opts);

private static readonly Regex Locale =
new(@"(?<![A-Z])((?:\p{Lu}{2,3}(-(CANS|CYRL|LATN|MONG))?-\p{Lu}{2})(?![A-Z])(?:-VALENCIA)?)", Opts);

private static readonly Regex SapPackage =
new(@"^(?:[\p{Lu}\p{Nd}]+[\._])+[\p{Lu}\p{Nd}]+(?:-(?:\p{Nd}+\.)+\p{Nd}+)(?:-(?:\p{Lu}{2}(?:_\p{Lu}{2})?|CORE))(?:-(?:\p{Lu}{2}|\p{Nd}{2}))$", Opts);

private static readonly Regex KbNumbers = new(@"\((KB\d+)\)", Opts);
private static readonly Regex NonLettersAndDigits = new(@"[^\p{L}\p{Nd}]", Opts);

private static readonly Regex UriProtocol = new(@"(?<!\p{L})(?:http[s]?|ftp)://", Opts);
private static readonly Regex VersionDelimited =
new(@"((?<!\p{L})(?:V|VER|VERSI(?:O|Ó)N|VERSÃO|VERSIE|WERSJA|BUILD|RELEASE|RC|SP)\P{L}?)?\p{Nd}+([\p{Po}\p{Pd}\p{Pc}]\p{Nd}?(RC|B|A|R|SP|K)?\p{Nd}+)+([\p{Po}\p{Pd}\p{Pc}]?[\p{L}\p{Nd}]+)*", Opts);
private static readonly Regex Version =
new(@"(FOR\s)?(?<!\p{L})(?:P|V|R|VER|VERSI(?:O|Ó)N|VERSÃO|VERSIE|WERSJA|BUILD|RELEASE|RC|SP)(?:\P{L}|\P{L}\p{L})?(\p{Nd}|\.\p{Nd})+(?:RC|B|A|R|V|SP)?\p{Nd}?", Opts);
private static readonly Regex VersionLetter =
new(@"(?<!\p{L})(?:(?:V|VER|VERSI(?:O|Ó)N|VERSÃO|VERSIE|WERSJA|BUILD|RELEASE|RC|SP)\P{L})?\p{Lu}\p{Nd}+(?:[\p{Po}\p{Pd}\p{Pc}]\p{Nd}+)+", Opts);
private static readonly Regex NonNestedBracket = new(@"\([^\(\)]*\)|\[[^\[\]]*\]", Opts);
private static readonly Regex BracketEnclosed = new("(?:\\p{Ps}.*\\p{Pe}|\".*\")", Opts);
private static readonly Regex LeadingSymbols = new(@"^[^\p{L}\p{Nd}]+", Opts);
private static readonly Regex TrailingNonLetters = new(@"\P{L}+$", Opts);
private static readonly Regex PrefixParens = new(@"^\(.*?\)", Opts);
private static readonly Regex EmptyParens = new("(\\(\\s*\\)|\\[\\s*\\]|\"\\s*\")", Opts);
private static readonly Regex EnSuffix = new(@"\sEN\s*$", Opts);
private static readonly Regex TrailingSymbols = new(@"[^\p{L}\p{Nd}]+$", Opts);
private static readonly Regex FilePath = new(@"((INSTALLED\sAT|IN)\s)?[CDEF]:\\(.+?\\)*[^\s]*\\?", Opts);
private static readonly Regex FilePathGhs = new(@"\(CHANGE\s#\d{1,2}\sTO\s[CDEF]:\\(.+?\\)*[^\s]*\\?\)", Opts);
private static readonly Regex FilePathParens = new(@"\([CDEF]:\\(.+?\\)*[^\s]*\\?\)", Opts);
private static readonly Regex FilePathQuotes = new("\"[CDEF]:\\\\(.+?\\\\)*[^\\s]*\\\\?\"", Opts);
private static readonly Regex Roblox = new(@"(?<=^ROBLOX\s(PLAYER|STUDIO))(\sFOR\s.*)", Opts);
private static readonly Regex Bomgar =
new(@"(?<=^BOMGAR\s(JUMP\sCLIENT|(ACCESS|REPRESENTATIVE)\sCONSOLE|BUTTON)|^EMBEDDED\sCALLBACK)(\s.*)", Opts);
private static readonly Regex AcronymSeparators =
new(@"(?:(?<=^\p{L})|(?<=\P{L}\p{L}))(\.|/)(?=\p{L}(?:\P{L}|$))", Opts);
private static readonly Regex NonLetters = new(@"(?<=^|\s)[^\p{L}]+(?=\s|$)", Opts);
private static readonly Regex ProgramNameSplit = new(@"[^\p{L}\p{Nd}\+\&]", Opts);
private static readonly Regex PublisherNameSplit = new(@"[^\p{L}\p{Nd}]", Opts);

// ── Regex patterns. Source-generated for NativeAOT compatibility —
// RegexOptions.Compiled would silently fall back to the interpreter
// under AOT, but [GeneratedRegex] produces specialized code at compile
// time. .NET regex supports variable-length lookbehind directly, so the
// C++ patterns port over unchanged. ──────────────────────────────────

[GeneratedRegex(@"(?<=^|[^\p{L}\p{Nd}])(X32|X86)(?=\P{Nd}|$)(?:\sEDITION)?", RegexOptions.IgnoreCase)]
private static partial Regex ArchitectureX32();
[GeneratedRegex(@"(?<=^|[^\p{L}\p{Nd}])(X64|AMD64|X86([\p{Pd}\p{Pc}]64))(?=\P{Nd}|$)(?:\sEDITION)?", RegexOptions.IgnoreCase)]
private static partial Regex ArchitectureX64();
[GeneratedRegex(@"(?<=^|[^\p{L}\p{Nd}])(32[\p{Pd}\p{Pc}\p{Z}]?BIT)S?(?:\sEDITION)?", RegexOptions.IgnoreCase)]
private static partial Regex Architecture32Bit();
[GeneratedRegex(@"(?<=^|[^\p{L}\p{Nd}])(64[\p{Pd}\p{Pc}\p{Z}]?BIT)S?(?:\sEDITION)?", RegexOptions.IgnoreCase)]
private static partial Regex Architecture64Bit();
[GeneratedRegex(@"(?<=^|[^\p{L}\p{Nd}])((64[\\/]32|32[\\/]64)[\p{Pd}\p{Pc}\p{Z}]?BIT)S?(?:\sEDITION)?", RegexOptions.IgnoreCase)]
private static partial Regex Architecture32Or64Bit();

[GeneratedRegex(@"(?<![A-Z])((?:\p{Lu}{2,3}(-(CANS|CYRL|LATN|MONG))?-\p{Lu}{2})(?![A-Z])(?:-VALENCIA)?)", RegexOptions.IgnoreCase)]
private static partial Regex Locale();

[GeneratedRegex(@"^(?:[\p{Lu}\p{Nd}]+[\._])+[\p{Lu}\p{Nd}]+(?:-(?:\p{Nd}+\.)+\p{Nd}+)(?:-(?:\p{Lu}{2}(?:_\p{Lu}{2})?|CORE))(?:-(?:\p{Lu}{2}|\p{Nd}{2}))$", RegexOptions.IgnoreCase)]
private static partial Regex SapPackage();

[GeneratedRegex(@"\((KB\d+)\)", RegexOptions.IgnoreCase)]
private static partial Regex KbNumbers();
[GeneratedRegex(@"[^\p{L}\p{Nd}]", RegexOptions.IgnoreCase)]
private static partial Regex NonLettersAndDigits();

[GeneratedRegex(@"(?<!\p{L})(?:http[s]?|ftp)://", RegexOptions.IgnoreCase)]
private static partial Regex UriProtocol();
[GeneratedRegex(@"((?<!\p{L})(?:V|VER|VERSI(?:O|Ó)N|VERSÃO|VERSIE|WERSJA|BUILD|RELEASE|RC|SP)\P{L}?)?\p{Nd}+([\p{Po}\p{Pd}\p{Pc}]\p{Nd}?(RC|B|A|R|SP|K)?\p{Nd}+)+([\p{Po}\p{Pd}\p{Pc}]?[\p{L}\p{Nd}]+)*", RegexOptions.IgnoreCase)]
private static partial Regex VersionDelimited();
[GeneratedRegex(@"(FOR\s)?(?<!\p{L})(?:P|V|R|VER|VERSI(?:O|Ó)N|VERSÃO|VERSIE|WERSJA|BUILD|RELEASE|RC|SP)(?:\P{L}|\P{L}\p{L})?(\p{Nd}|\.\p{Nd})+(?:RC|B|A|R|V|SP)?\p{Nd}?", RegexOptions.IgnoreCase)]
private static partial Regex Version();
[GeneratedRegex(@"(?<!\p{L})(?:(?:V|VER|VERSI(?:O|Ó)N|VERSÃO|VERSIE|WERSJA|BUILD|RELEASE|RC|SP)\P{L})?\p{Lu}\p{Nd}+(?:[\p{Po}\p{Pd}\p{Pc}]\p{Nd}+)+", RegexOptions.IgnoreCase)]
private static partial Regex VersionLetter();
[GeneratedRegex(@"\([^\(\)]*\)|\[[^\[\]]*\]", RegexOptions.IgnoreCase)]
private static partial Regex NonNestedBracket();
[GeneratedRegex("(?:\\p{Ps}.*\\p{Pe}|\".*\")", RegexOptions.IgnoreCase)]
private static partial Regex BracketEnclosed();
[GeneratedRegex(@"^[^\p{L}\p{Nd}]+", RegexOptions.IgnoreCase)]
private static partial Regex LeadingSymbols();
[GeneratedRegex(@"\P{L}+$", RegexOptions.IgnoreCase)]
private static partial Regex TrailingNonLetters();
[GeneratedRegex(@"^\(.*?\)", RegexOptions.IgnoreCase)]
private static partial Regex PrefixParens();
[GeneratedRegex("(\\(\\s*\\)|\\[\\s*\\]|\"\\s*\")", RegexOptions.IgnoreCase)]
private static partial Regex EmptyParens();
[GeneratedRegex(@"\sEN\s*$", RegexOptions.IgnoreCase)]
private static partial Regex EnSuffix();
[GeneratedRegex(@"[^\p{L}\p{Nd}]+$", RegexOptions.IgnoreCase)]
private static partial Regex TrailingSymbols();
[GeneratedRegex(@"((INSTALLED\sAT|IN)\s)?[CDEF]:\\(.+?\\)*[^\s]*\\?", RegexOptions.IgnoreCase)]
private static partial Regex FilePath();
[GeneratedRegex(@"\(CHANGE\s#\d{1,2}\sTO\s[CDEF]:\\(.+?\\)*[^\s]*\\?\)", RegexOptions.IgnoreCase)]
private static partial Regex FilePathGhs();
[GeneratedRegex(@"\([CDEF]:\\(.+?\\)*[^\s]*\\?\)", RegexOptions.IgnoreCase)]
private static partial Regex FilePathParens();
[GeneratedRegex("\"[CDEF]:\\\\(.+?\\\\)*[^\\s]*\\\\?\"", RegexOptions.IgnoreCase)]
private static partial Regex FilePathQuotes();
[GeneratedRegex(@"(?<=^ROBLOX\s(PLAYER|STUDIO))(\sFOR\s.*)", RegexOptions.IgnoreCase)]
private static partial Regex Roblox();
[GeneratedRegex(@"(?<=^BOMGAR\s(JUMP\sCLIENT|(ACCESS|REPRESENTATIVE)\sCONSOLE|BUTTON)|^EMBEDDED\sCALLBACK)(\s.*)", RegexOptions.IgnoreCase)]
private static partial Regex Bomgar();
[GeneratedRegex(@"(?:(?<=^\p{L})|(?<=\P{L}\p{L}))(\.|/)(?=\p{L}(?:\P{L}|$))", RegexOptions.IgnoreCase)]
private static partial Regex AcronymSeparators();
[GeneratedRegex(@"(?<=^|\s)[^\p{L}]+(?=\s|$)", RegexOptions.IgnoreCase)]
private static partial Regex NonLetters();
[GeneratedRegex(@"[^\p{L}\p{Nd}\+\&]", RegexOptions.IgnoreCase)]
private static partial Regex ProgramNameSplit();
[GeneratedRegex(@"[^\p{L}\p{Nd}]", RegexOptions.IgnoreCase)]
private static partial Regex PublisherNameSplit();

// Source-generated regex methods return cached singletons, so building
// these arrays once is just storing the cached references.
private static readonly Regex[] ProgramNameRegexes =
[
Roblox, Bomgar, PrefixParens, EmptyParens,
FilePathGhs, FilePathParens, FilePathQuotes, FilePath,
VersionLetter, VersionDelimited, Version, EnSuffix,
NonNestedBracket, BracketEnclosed, UriProtocol,
LeadingSymbols, TrailingSymbols,
Roblox(), Bomgar(), PrefixParens(), EmptyParens(),
FilePathGhs(), FilePathParens(), FilePathQuotes(), FilePath(),
VersionLetter(), VersionDelimited(), Version(), EnSuffix(),
NonNestedBracket(), BracketEnclosed(), UriProtocol(),
LeadingSymbols(), TrailingSymbols(),
];

private static readonly Regex[] PublisherNameRegexes =
[
VersionDelimited, Version, NonNestedBracket, BracketEnclosed,
UriProtocol, NonLetters, TrailingNonLetters, AcronymSeparators,
VersionDelimited(), Version(), NonNestedBracket(), BracketEnclosed(),
UriProtocol(), NonLetters(), TrailingNonLetters(), AcronymSeparators(),
];

// ── Locale + legal-entity-suffix lists ─────────────────────────────────
Expand Down
Loading
Loading