Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
0ea6b76
Enforce explicit ID filters on REST search results
AmelBawa-msft Sep 10, 2026
8660e86
Allow EApp in spelling checks (#6515)
Copilot Sep 10, 2026
cacf919
Fix build
AmelBawa-msft Sep 10, 2026
64ae352
Merge branch 'user/amelbawa/source-filter' of https://github.com/micr…
AmelBawa-msft Sep 10, 2026
c6aa362
Guard against null comparators when resolving matches
AmelBawa-msft Sep 18, 2026
251ea69
Add tri-state matching for search filters and inclusions
AmelBawa-msft Sep 18, 2026
0604413
Share validated REST requests between serialization and filtering
AmelBawa-msft Sep 19, 2026
eef6502
Enforce ID inclusions when filtering REST search results
AmelBawa-msft Sep 19, 2026
b5fa8ea
Validate REST search results using available package metadata
AmelBawa-msft Sep 19, 2026
e8f3f70
Add lazy resolution for unknown search match criteria
AmelBawa-msft Sep 21, 2026
a013904
Fetch missing manifest metadata to validate REST search results
AmelBawa-msft Sep 22, 2026
ed905db
Prevent infinite REST searches from repeated continuation tokens
AmelBawa-msft Sep 22, 2026
7d891f2
Allow case-sensitivity test values in spelling checks
AmelBawa-msft Sep 23, 2026
43921fe
Preserve manifest name-publisher pairs and use 64-bit CI tools
AmelBawa-msft Sep 23, 2026
53b3ae7
Pull latest changes from master
AmelBawa-msft Sep 24, 2026
1c849e7
Limit additional manifest retrievals during REST searches
AmelBawa-msft Sep 26, 2026
a6201df
Preserve localized name-publisher pairs without a default name
AmelBawa-msft Sep 26, 2026
a11d9ca
Chain REST 1.1 search validation through the 1.0 validator
AmelBawa-msft Sep 26, 2026
a0a82a5
Expose name-publisher pairs through package matrix properties
AmelBawa-msft Sep 26, 2026
50dcbf9
Deduplicate name-publisher pairs in REST package metadata
AmelBawa-msft Sep 26, 2026
4ffdffc
Centralize case-preserving manifest package name extraction
AmelBawa-msft Sep 26, 2026
3a435af
Simplify tri-state request matching and lazy resolution
AmelBawa-msft Sep 26, 2026
2fde54b
Use direct ID validation for optimized REST searches
AmelBawa-msft Sep 26, 2026
45e6392
Share REST manifest selection and extract cache population
AmelBawa-msft Sep 26, 2026
8e1ff75
Remove spelling exceptions by separating test words
AmelBawa-msft Sep 26, 2026
603187d
Fail optimized REST lookups on mismatched manifest IDs
AmelBawa-msft Sep 28, 2026
207b73e
Skip manifest name-publisher pairs without a resolved name
AmelBawa-msft Sep 28, 2026
6518609
Extract search request evaluation into a dedicated helper
AmelBawa-msft Sep 29, 2026
7a8f2a0
Document REST normalized name-publisher validation limitations
AmelBawa-msft Sep 29, 2026
9d119a3
Simplify REST manifest caching and validate query parameters once
AmelBawa-msft Sep 29, 2026
5f96466
Limit REST manifest enrichment to three or fewer total results
AmelBawa-msft Sep 29, 2026
fcddbc9
Fix x86 signedness warning in REST continuation test
AmelBawa-msft Sep 29, 2026
a897d9c
Preserve REST search references for installed-package correlation
AmelBawa-msft Sep 30, 2026
bed1834
Remove redundant release note for REST correlation fix
AmelBawa-msft Sep 30, 2026
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
1 change: 1 addition & 0 deletions .github/actions/spelling/allow.txt
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ dotnet
downloaders
dsx
DWORDLONG
EApp
emoji
ENDDIALOG
ensureandinsert
Expand Down
2 changes: 2 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ The solution uses:
- vcpkg for C++ dependencies
- NuGet for C++ and .NET dependencies

CI uses `/p:PreferredToolArchitecture=x64` to avoid 32-bit linker memory limits without changing the target architecture. Use the same setting for command-line Release builds.

### Running/Debugging

1. Deploy solution: Build > Deploy Solution
Expand Down
5 changes: 5 additions & 0 deletions doc/ReleaseNotes.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ This change resolves alias failures in non-symlinked scenarios, including cases
Because the alias is now created as an executable hardlink in the install location, command aliases remain available and consistent even when symlink creation is skipped.

### Minor Bug Fixes
* Fixed REST search results bypassing locally verifiable package filters and selectors. Extra manifests are retrieved only for complete source result sets of three or fewer packages. Normalized name/publisher criteria remain unvalidated client-side.
* Fixed installed-package matching incorrectly combining names and publishers from different manifest entries.
* Prevented unrestricted REST searches when a source declares all requested selectors unsupported.
* Prevented REST searches from looping indefinitely when continuation tokens repeat.
* Fixed Unicode case-insensitive prefix matching when case folding changes character lengths.
* Fixed an issue where `winget search --id <msstoreId>` could fail to return a Microsoft Store package unless `--exact` was also provided.
* Updated NUnit to v4
* Fixed a crash (`0x8000ffff`) when using `--disable-interactivity` with the Resume experimental feature enabled during install operations.
Expand Down
286 changes: 286 additions & 0 deletions src/AppInstallerCLITests/CompositeSource.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
#include "TestCommon.h"
#include "TestSource.h"
#include "TestHooks.h"
#include "TestRestRequestHandler.h"
#include <CompositeSource.h>
#include <Microsoft/SQLiteIndexSource.h>
#include <Microsoft/PinningIndex.h>
#include <PackageTrackingCatalogSourceFactory.h>
#include <Rest/RestSource.h>
#include <winget/Pin.h>
#include <winget/PinningData.h>
#include <winget/PackageVersionSelection.h>
Expand Down Expand Up @@ -438,6 +440,64 @@ TEST_CASE("CompositeSource_ProductCode_Available", "[CompositeSource]")
REQUIRE(result.Matches[0].Package->GetAvailable()[0]->GetVersionKeys().size() == 1);
}

TEST_CASE("Package_MatrixProperty_NameAndPublisher", "[CompositeSource]")
{
bool hasNames = GENERATE(false, true);
bool hasPublishers = GENERATE(false, true);
CAPTURE(hasNames, hasPublishers);
Manifest::Manifest manifest;
manifest.Version = "1.0";
auto& localization = manifest.Localizations.emplace_back();
if (hasNames)
{
manifest.DefaultLocalization.Add<Manifest::Localization::PackageName>("First Name");
localization.Add<Manifest::Localization::PackageName>("Second Name");
}
if (hasPublishers)
{
manifest.DefaultLocalization.Add<Manifest::Localization::Publisher>("First Publisher");
localization.Add<Manifest::Localization::Publisher>("Second Publisher");
}
auto package = TestPackage::Make(std::vector<Manifest::Manifest>{ manifest });
std::vector<std::vector<std::string>> expected;
if (hasNames && hasPublishers)
{
expected = {
{ "first name", "first publisher" },
{ "first name", "second publisher" },
{ "second name", "first publisher" },
{ "second name", "second publisher" },
};
}
REQUIRE(package->GetMatrixProperty(PackageMatrixProperty::NormalizedNameAndPublisher) == expected);
REQUIRE_THROWS_HR(package->GetMatrixProperty(static_cast<PackageMatrixProperty>(-1)), E_UNEXPECTED);
}

TEST_CASE("CompositeSource_NameAndPublisher_InvalidMatrixRow", "[CompositeSource]")
{
struct TestMatrixPackage : TestPackage
{
using TestPackage::TestPackage;

std::vector<std::vector<std::string>> GetMatrixProperty(PackageMatrixProperty) const override
{
return Rows;
}

std::vector<std::vector<std::string>> Rows;
};

size_t columnCount = GENERATE(size_t{ 0 }, size_t{ 1 }, size_t{ 3 });
CAPTURE(columnCount);
CompositeTestSetup setup{ CompositeSearchBehavior::AvailablePackages };
auto package = std::make_shared<TestMatrixPackage>(std::vector<Manifest::Manifest>{ MakeDefaultManifest() }, setup.Available);
package->Rows.emplace_back(columnCount, "value");
auto available = setup.MakeAvailable().ToPackage();
available->Available[0] = package;
setup.Available->Everything.Matches.emplace_back(available, Criteria());
REQUIRE_THROWS_HR(setup.Search(), E_UNEXPECTED);
}

TEST_CASE("CompositeSource_NameAndPublisher_Match", "[CompositeSource]")
{
CompositeTestSetup setup;
Expand Down Expand Up @@ -2037,3 +2097,229 @@ TEST_CASE("CompositeSource_MappedVersions_ProperSorting", "[CompositeSource]")
REQUIRE(installedVersions[0].Version == versionMapped2);
REQUIRE(installedVersions[1].Version == versionMapped1);
}

struct RestCorrelationTestSetup : CompositeWithTrackingTestSetup
{
web::json::value SearchResponse = web::json::value::parse(LR"({
"Data": [{
"PackageIdentifier": "Foo.Bar", "PackageName": "Legacy App", "Publisher": "Legacy Publisher",
"Versions": [{ "PackageVersion": "Unknown" }]
}]
})");
web::json::value ManifestResponse = web::json::value::parse(LR"({
"Data": {
"PackageIdentifier": "Foo.Bar",
"Versions": [{
"PackageVersion": "1.0.0",
"DefaultLocale": {
"PackageLocale": "en-US", "PackageName": "New App", "Publisher": "New Publisher", "Moniker": "tool",
"License": "MIT", "ShortDescription": "Example application"
},
"Installers": [{
"Architecture": "x64", "InstallerType": "exe", "InstallerUrl": "https://example.com/installer.exe",
"InstallerSha256": "011048877dfaef109801b3f3ab2b60afc74f3fc4f7b3430e0c897f5da1df84b6"
}]
}]
}
})");
size_t ManifestRequests = 0;

RestCorrelationTestSetup(CompositeSearchBehavior behavior)
{
namespace RepositoryRest = AppInstaller::Repository::Rest;
auto handler = std::make_shared<TestRestRequestHandler>(
[this](web::http::http_request request) -> pplx::task<web::http::http_response>
{
web::http::http_response response{ web::http::status_codes::BadRequest };
response.headers().set_content_type(web::http::details::mime_types::application_json);
response.headers().set_cache_control(L"no-store");
if (request.method() == web::http::methods::POST)
{
response.set_status_code(web::http::status_codes::OK);
response.set_body(SearchResponse);
}
else if (request.method() == web::http::methods::GET)
{
++ManifestRequests;
response.set_status_code(web::http::status_codes::OK);
response.set_body(ManifestResponse);
}
return pplx::task_from_result(response);
});
Http::HttpClientHelper helper{ handler };
SourceDetails details;
details.Identifier = "RestCorrelationTestSource";
auto source = std::make_shared<RepositoryRest::RestSource>(details, SourceInformation{},
RepositoryRest::RestClient::Create("https://restsource.com/api", {}, {}, helper,
RepositoryRest::Schema::IRestClient::Information{ details.Identifier, { "1.4.0" } }));
Composite = CompositeSource{ "*RestTests" };
Composite.SetInstalledSource(Source{ Installed }, behavior);
Composite.AddAvailableSource(Source{ source });
}
};

TEST_CASE("CompositeSource_RestRetrieval_InstalledVersion", "[RestSource][CompositeSource][RestRetrievalRegression]")
{
auto [manifestHasArpRanges, onlyLatestVersion] = GENERATE(
std::make_pair(false, false), std::make_pair(false, true), std::make_pair(true, false));
bool retrieveBeforeCorrelation = GENERATE(false, true);
CAPTURE(manifestHasArpRanges, onlyLatestVersion, retrieveBeforeCorrelation);
RestCorrelationTestSetup setup{ CompositeSearchBehavior::AvailablePackages };
auto& searchVersion = setup.SearchResponse[L"Data"][0][L"Versions"][0];
searchVersion[L"ProductCodes"][0] = web::json::value::string(L"search.code");
searchVersion[L"AppsAndFeaturesEntryVersions"] = web::json::value::array(
{ web::json::value::string(L"1.0.0"), web::json::value::string(L"2.0.0") });
auto first = setup.ManifestResponse[L"Data"][L"Versions"][0];
auto second = first;
first[L"PackageVersion"] = web::json::value::string(manifestHasArpRanges ? L"10.0.0" : L"1.0.0");
second[L"PackageVersion"] = web::json::value::string(manifestHasArpRanges ? L"20.0.0" : L"2.0.0");
if (manifestHasArpRanges)
{
first[L"Installers"][0][L"AppsAndFeaturesEntries"][0][L"DisplayVersion"] = web::json::value::string(L"1.0.0");
second[L"Installers"][0][L"AppsAndFeaturesEntries"][0][L"DisplayVersion"] = web::json::value::string(L"2.0.0");
}
setup.ManifestResponse[L"Data"][L"Versions"] = onlyLatestVersion ?
web::json::value::array({ second }) : web::json::value::array({ first, second });
auto installed = setup.MakeInstalled().WithVersion("1.0.0").WithPC("search.code")
.WithMetadata(PackageVersionMetadata::InstalledType, "exe").ToPackage();
setup.Installed->SearchFunction = [&](const SearchRequest& request)
{
SearchResult result;
if (request.Purpose == SearchPurpose::CorrelationToInstalled &&
SearchRequestIncludes(request.Inclusions, PackageMatchField::ProductCode, MatchType::Exact, "search.code"))
{
result.Matches.emplace_back(installed, PackageMatchFilter{ PackageMatchField::ProductCode, MatchType::Exact, "search.code" });
}
return result;
};
SearchRequest request;
request.Filters.emplace_back(retrieveBeforeCorrelation ? PackageMatchField::Moniker : PackageMatchField::Name,
MatchType::Exact, retrieveBeforeCorrelation ? "tool"sv : "Legacy App"sv);
auto result = setup.Composite.Search(request);
REQUIRE(result.Failures.empty());
REQUIRE(result.Matches.size() == 1);
auto installedVersion = GetInstalledVersion(result.Matches[0].Package);
REQUIRE(installedVersion);
CHECK(installedVersion->GetProperty(PackageVersionProperty::Version).get() == (manifestHasArpRanges ? "10.0.0" : "1.0.0"));
auto latestAvailable = GetAvailableVersionsForInstalledVersion(result.Matches[0].Package)->GetLatestVersion();
REQUIRE(latestAvailable);
REQUIRE(latestAvailable->GetProperty(PackageVersionProperty::Version).get() == (manifestHasArpRanges ? "20.0.0" : "2.0.0"));
PinningData::PinStateEvaluator evaluator{ PinBehavior::IgnorePins, {}, installedVersion };
CHECK(evaluator.IsUpdate(latestAvailable));
REQUIRE(setup.ManifestRequests == 1);
}

TEST_CASE("CompositeSource_RestRetrieval_NamePublisher", "[RestSource][CompositeSource][RestRetrievalRegression]")
{
auto behavior = GENERATE(CompositeSearchBehavior::Installed, CompositeSearchBehavior::AvailablePackages);
auto versionState = GENERATE("Known"sv, "Unknown"sv, "PartiallyCached"sv);
bool legacyName = GENERATE(false, true);
bool legacyPublisher = GENERATE(false, true);
CAPTURE(behavior, versionState, legacyName, legacyPublisher);
RestCorrelationTestSetup setup{ behavior };
if (versionState != "Unknown")
{
setup.SearchResponse[L"Data"][0][L"Versions"][0][L"PackageVersion"] = web::json::value::string(L"1.0.0");
}
if (versionState == "PartiallyCached")
{
setup.SearchResponse[L"Data"][0][L"Versions"][1][L"PackageVersion"] = web::json::value::string(L"2.0.0");
}
const std::string name = legacyName ? "Legacy App" : "New App";
const std::string publisher = legacyPublisher ? "Legacy Publisher" : "New Publisher";
auto installedManifest = MakeDefaultManifest("1.0.0");
installedManifest.DefaultLocalization.Add<Manifest::Localization::PackageName>(name);
installedManifest.DefaultLocalization.Add<Manifest::Localization::Publisher>(publisher);
auto installed = TestCompositePackage::Make(installedManifest, TestCompositePackage::MetadataMap{},
std::vector<Manifest::Manifest>{}, setup.Installed);
setup.Installed->SearchFunction = [&](const SearchRequest& request)
{
SearchResult result;
if (request.Purpose == SearchPurpose::CorrelationToInstalled)
{
for (const auto& inclusion : request.Inclusions)
{
if (inclusion.Field == PackageMatchField::NormalizedNameAndPublisher && inclusion.Additional &&
ICUCaseInsensitiveEquals(inclusion.Value, name) &&
ICUCaseInsensitiveEquals(inclusion.Additional.value(), publisher))
{
result.Matches.emplace_back(installed, inclusion);
break;
}
}
}
return result;
};
SearchRequest request;
request.Filters.emplace_back(PackageMatchField::Moniker, MatchType::Exact, "tool");
auto result = setup.Composite.Search(request);
REQUIRE(result.Failures.empty());
bool shouldCorrelate = legacyName == legacyPublisher;
size_t expectedCount = behavior == CompositeSearchBehavior::Installed && !shouldCorrelate ? 0 : 1;
REQUIRE(result.Matches.size() == expectedCount);
if (expectedCount)
{
REQUIRE(static_cast<bool>(GetInstalledVersion(result.Matches[0].Package)) == shouldCorrelate);
REQUIRE(result.Matches[0].Package->GetAvailable().size() == 1);
}
REQUIRE(setup.ManifestRequests == (versionState == "PartiallyCached" ? size_t{ 2 } : size_t{ 1 }));
}

TEST_CASE("CompositeSource_RestRetrieval_ManifestNamePublisherPairs", "[RestSource][CompositeSource][RestRetrievalRegression]")
{
auto behavior = GENERATE(CompositeSearchBehavior::Installed, CompositeSearchBehavior::AvailablePackages);
bool cacheManifest = GENERATE(false, true);
size_t nameIndex = GENERATE(0, 1, 2);
size_t publisherIndex = GENERATE(0, 1, 2);
const std::vector<std::string> names{ "New App", "Localized App", "Installed App" };
const std::vector<std::string> publishers{ "New Publisher", "Localized Publisher", "Installed Publisher" };
const auto& name = names[nameIndex];
const auto& publisher = publishers[publisherIndex];
CAPTURE(behavior, cacheManifest, name, publisher);
RestCorrelationTestSetup setup{ behavior };
setup.SearchResponse[L"Data"][0][L"Versions"][0][L"PackageVersion"] = web::json::value::string(L"1.0.0");
auto& manifestVersion = setup.ManifestResponse[L"Data"][L"Versions"][0];
manifestVersion[L"Locales"] = web::json::value::parse(LR"([
{ "PackageLocale": "fr-FR", "PackageName": "Localized App", "Publisher": "Localized Publisher" }
])");
manifestVersion[L"Installers"][0][L"AppsAndFeaturesEntries"] = web::json::value::parse(LR"([
{ "DisplayName": "Installed App", "Publisher": "Installed Publisher" }
])");
auto installedManifest = MakeDefaultManifest("1.0.0");
installedManifest.DefaultLocalization.Add<Manifest::Localization::PackageName>(name);
installedManifest.DefaultLocalization.Add<Manifest::Localization::Publisher>(publisher);
auto installed = TestCompositePackage::Make(installedManifest, TestCompositePackage::MetadataMap{},
std::vector<Manifest::Manifest>{}, setup.Installed);
setup.Installed->SearchFunction = [&](const SearchRequest& request)
{
SearchResult result;
if (request.Purpose == SearchPurpose::CorrelationToInstalled)
{
for (const auto& inclusion : request.Inclusions)
{
if (inclusion.Field == PackageMatchField::NormalizedNameAndPublisher && inclusion.Additional &&
ICUCaseInsensitiveEquals(inclusion.Value, name) &&
ICUCaseInsensitiveEquals(inclusion.Additional.value(), publisher))
{
result.Matches.emplace_back(installed, inclusion);
break;
}
}
}
return result;
};
SearchRequest request;
request.Filters.emplace_back(cacheManifest ? PackageMatchField::Moniker : PackageMatchField::Name,
MatchType::Exact, cacheManifest ? "tool"sv : "Legacy App"sv);
auto result = setup.Composite.Search(request);
REQUIRE(result.Failures.empty());
bool shouldCorrelate = nameIndex == publisherIndex;
size_t expectedCount = behavior == CompositeSearchBehavior::Installed && !shouldCorrelate ? 0 : 1;
REQUIRE(result.Matches.size() == expectedCount);
if (expectedCount)
{
REQUIRE(static_cast<bool>(GetInstalledVersion(result.Matches[0].Package)) == shouldCorrelate);
REQUIRE(result.Matches[0].Package->GetAvailable().size() == 1);
}
REQUIRE(setup.ManifestRequests == 1);
}
Loading
Loading