diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index 200d4b29f7..d7eeb24dfd 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -80,6 +80,7 @@ dotnet downloaders dsx DWORDLONG +EApp emoji ENDDIALOG ensureandinsert diff --git a/doc/ReleaseNotes.md b/doc/ReleaseNotes.md index a9622f87f5..922aa8f777 100644 --- a/doc/ReleaseNotes.md +++ b/doc/ReleaseNotes.md @@ -18,6 +18,8 @@ Added a new `--ignore-unavailable` flag to the `install` command. When installin ## Bug Fixes +* Fixed REST search results bypassing explicit package ID filters, including when combined with name filters. +* Fixed Unicode case-insensitive prefix matching when case folding changes character lengths. * Fixed an issue where `winget search --id ` 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. diff --git a/src/AppInstallerCLITests/MatchCriteriaResolver.cpp b/src/AppInstallerCLITests/MatchCriteriaResolver.cpp index 2854eb71b2..cded8e562c 100644 --- a/src/AppInstallerCLITests/MatchCriteriaResolver.cpp +++ b/src/AppInstallerCLITests/MatchCriteriaResolver.cpp @@ -20,6 +20,47 @@ void RequireMatchCriteria(const PackageMatchFilter& expected, const PackageMatch REQUIRE(expected.Value == actual.Value); } +TEST_CASE("MatchCriteriaResolver_MatchesRequest", "[MatchCriteriaResolver]") +{ + struct MatchCase + { + MatchType Type; + std::string_view Query; + std::string_view Value; + bool Expected; + }; + + const MatchCase cases[] = + { + { MatchType::Exact, "Foo.Bar", "Foo.Bar", true }, + { MatchType::Exact, "foo.bar", "Foo.Bar", false }, + { MatchType::Exact, "Foo", "Foo.Bar", false }, + { MatchType::CaseInsensitive, "foo.bar", "Foo.Bar", true }, + { MatchType::CaseInsensitive, "foo", "Foo.Bar", false }, + { MatchType::StartsWith, "foo", "Foo.Bar", true }, + { MatchType::StartsWith, "bar", "Foo.Bar", false }, + { MatchType::Substring, "BAR", "Foo.Bar", true }, + { MatchType::Substring, "Baz", "Foo.Bar", false }, + { MatchType::Exact, "caf\xC3\xA9", "cafe\xCC\x81", true }, + { MatchType::CaseInsensitive, "CAF\xC3\x89", "caf\xC3\xA9", true }, + { MatchType::Exact, "", "Foo.Bar", false }, + }; + + for (const auto& test : cases) + { + CAPTURE(ToString(test.Type), test.Query, test.Value); + auto result = MatchesRequest(RequestMatch{ test.Type, test.Query }, test.Value); + REQUIRE(result.has_value()); + REQUIRE(result.value() == test.Expected); + } +} + +TEST_CASE("MatchCriteriaResolver_MatchesRequest_Unsupported", "[MatchCriteriaResolver]") +{ + auto type = GENERATE(MatchType::Fuzzy, MatchType::FuzzySubstring, MatchType::Wildcard); + REQUIRE_FALSE(MatchesRequest(RequestMatch{ type, "Foo" }, "Foo.Bar").has_value()); +} + TEST_CASE("MatchCriteriaResolver_MatchType", "[MatchCriteriaResolver]") { Manifest::Manifest manifest; diff --git a/src/AppInstallerCLITests/RestInterface_1_0.cpp b/src/AppInstallerCLITests/RestInterface_1_0.cpp index 374f61e568..c2bd28d7c9 100644 --- a/src/AppInstallerCLITests/RestInterface_1_0.cpp +++ b/src/AppInstallerCLITests/RestInterface_1_0.cpp @@ -23,6 +23,30 @@ namespace { const std::string TestRestUriString = "http://restsource.com/api"; + utility::string_t GetSearchResponse_PackageIds( + std::initializer_list identifiers, const utility::string_t& continuationToken = {}) + { + web::json::value response; + response[L"Data"] = web::json::value::array(); + size_t index = 0; + for (const auto& identifier : identifiers) + { + web::json::value package; + package[L"PackageIdentifier"] = web::json::value::string(identifier); + package[L"PackageName"] = web::json::value::string(L"Microsoft Teams"); + package[L"Publisher"] = web::json::value::string(L"Microsoft"); + package[L"Versions"][0][L"PackageVersion"] = web::json::value::string(L"1.0.0"); + response[L"Data"][index++] = std::move(package); + } + + if (!continuationToken.empty()) + { + response[L"ContinuationToken"] = web::json::value::string(continuationToken); + } + + return response.serialize(); + } + utility::string_t GetGoodManifest_RequiredFields() { return _XPLATSTR( @@ -398,6 +422,188 @@ TEST_CASE("Search_GoodResponse_404AsEmpty", "[RestSource][Interface_1_0]") REQUIRE(searchResponse.Matches.size() == 0); } +TEST_CASE("Search_ExplicitIdFilters", "[RestSource][Interface_1_0]") +{ + HttpClientHelper helper{ GetTestRestRequestHandler(web::http::status_codes::OK, + GetSearchResponse_PackageIds({ L"XP8BT8DW290MPQ", L"Microsoft.Teams", L"Microsoft.Teams.Preview" })) }; + Interface v1{ TestRestUriString, helper }; + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Name, MatchType::CaseInsensitive, "Microsoft Teams"); + std::vector expected{ "Microsoft.Teams" }; + + SECTION("Exact") + { + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Exact, "Microsoft.Teams"); + } + SECTION("Exact case mismatch") + { + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Exact, "microsoft.teams"); + expected.clear(); + } + SECTION("Case insensitive") + { + request.Filters.emplace_back(PackageMatchField::Id, MatchType::CaseInsensitive, "microsoft.teams"); + } + SECTION("Starts with") + { + request.Filters.emplace_back(PackageMatchField::Id, MatchType::StartsWith, "microsoft.teams"); + expected.emplace_back("Microsoft.Teams.Preview"); + } + SECTION("Substring") + { + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Substring, "teams"); + expected.emplace_back("Microsoft.Teams.Preview"); + } + SECTION("All ID filters must match") + { + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Exact, "Microsoft.Teams"); + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Exact, "Microsoft.Teams.Preview"); + expected.clear(); + } + SECTION("Query and inclusions cannot override a failed filter") + { + request.Query.emplace(MatchType::Substring, "Teams"); + request.Inclusions.emplace_back(PackageMatchField::Name, MatchType::Exact, "Microsoft Teams"); + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Exact, "Other.Package"); + expected.clear(); + } + + auto result = v1.Search(request); + REQUIRE(result.Matches.size() == expected.size()); + REQUIRE_FALSE(result.Truncated); + for (size_t i = 0; i < expected.size(); ++i) + { + REQUIRE(result.Matches[i].PackageInformation.PackageIdentifier == expected[i]); + } +} + +TEST_CASE("Search_ExplicitIdFilters_UnicodePrefix", "[RestSource][Interface_1_0]") +{ + std::wstring id = GENERATE(L"Vendor.\u1E9EApp", L"Vendor.\u00DFApp", L"Vendor.SSApp"); + std::string prefix = GENERATE(u8"vendor.\u00DF", u8"vendor.\u1E9E", "vendor.ss"); + CAPTURE(id, prefix); + HttpClientHelper helper{ GetTestRestRequestHandler(web::http::status_codes::OK, + GetSearchResponse_PackageIds({ L"Vendor.Other", id })) }; + Interface v1{ TestRestUriString, helper }; + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Id, MatchType::StartsWith, prefix); + request.MaximumResults = 1; + + auto result = v1.Search(request); + REQUIRE(result.Matches.size() == 1); + REQUIRE(result.Matches[0].PackageInformation.PackageIdentifier == ConvertToUTF8(id)); + REQUIRE_FALSE(result.Truncated); +} + +TEST_CASE("Search_ExplicitIdFilters_UnsupportedMatchType", "[RestSource][Interface_1_0]") +{ + HttpClientHelper helper{ GetTestRestRequestHandler(web::http::status_codes::OK, + GetSearchResponse_PackageIds({ L"Foo.Bar" })) }; + Interface v1{ TestRestUriString, helper }; + SearchRequest request; + auto type = GENERATE(MatchType::Fuzzy, MatchType::FuzzySubstring, MatchType::Wildcard); + request.Filters.emplace_back(PackageMatchField::Id, type, "Other"); + + auto result = v1.Search(request); + REQUIRE(result.Matches.size() == 1); + REQUIRE(result.Matches[0].PackageInformation.PackageIdentifier == "Foo.Bar"); +} + +TEST_CASE("Search_ExplicitIdFilters_UnavailableMetadata", "[RestSource][Interface_1_0]") +{ + HttpClientHelper helper{ GetTestRestRequestHandler(web::http::status_codes::OK, + GetSearchResponse_PackageIds({ L"Foo.Bar" })) }; + Interface v1{ TestRestUriString, helper }; + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Exact, "Foo.Bar"); + auto field = GENERATE(PackageMatchField::Name, PackageMatchField::Moniker, PackageMatchField::Tag, + PackageMatchField::Command, PackageMatchField::PackageFamilyName, PackageMatchField::ProductCode, + PackageMatchField::UpgradeCode, PackageMatchField::NormalizedNameAndPublisher, PackageMatchField::Market); + request.Filters.emplace_back(field, MatchType::Exact, "Not in the response"); + + auto result = v1.Search(request); + REQUIRE(result.Matches.size() == 1); + REQUIRE(result.Matches[0].PackageInformation.PackageIdentifier == "Foo.Bar"); +} + +TEST_CASE("Search_ExplicitIdFilters_NoFilters", "[RestSource][Interface_1_0]") +{ + HttpClientHelper helper{ GetTestRestRequestHandler(web::http::status_codes::OK, + GetSearchResponse_PackageIds({ L"Foo.Bar" })) }; + Interface v1{ TestRestUriString, helper }; + SearchRequest request; + + SECTION("Everything") {} + SECTION("Query") + { + request.Query.emplace(MatchType::Exact, "Not in the response"); + } + SECTION("Inclusions") + { + request.Inclusions.emplace_back(PackageMatchField::Id, MatchType::Exact, "Not in the response"); + } + SECTION("Correlation") + { + request.Purpose = SearchPurpose::CorrelationToAvailable; + request.Inclusions.emplace_back(PackageMatchField::ProductCode, MatchType::Exact, "Not in the response"); + } + + auto result = v1.Search(request); + REQUIRE(result.Matches.size() == 1); + REQUIRE(result.Matches[0].PackageInformation.PackageIdentifier == "Foo.Bar"); +} + +TEST_CASE("Search_ExplicitIdFilters_Continuation", "[RestSource][Interface_1_0]") +{ + bool allFiltered = GENERATE(false, true); + std::vector pages + { + GetSearchResponse_PackageIds({ L"Other.One", L"Other.Two" }, L"next"), + GetSearchResponse_PackageIds({ allFiltered ? L"Other.Three" : L"Match.One" }, L"last"), + GetSearchResponse_PackageIds({ allFiltered ? L"Other.Four" : L"Match.Two", + allFiltered ? L"Other.Five" : L"Match.Three" }), + }; + std::vector continuationTokens; + size_t requestCount = 0; + auto handler = std::make_shared( + [&](web::http::http_request request) -> pplx::task + { + 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 && requestCount < pages.size()) + { + continuationTokens.emplace_back(request.headers()[L"ContinuationToken"]); + response.set_status_code(web::http::status_codes::OK); + response.set_body(web::json::value::parse(pages[requestCount])); + } + ++requestCount; + return pplx::task_from_result(response); + }); + HttpClientHelper helper{ handler }; + Interface v1{ TestRestUriString, helper }; + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Id, MatchType::StartsWith, "Match."); + request.MaximumResults = GENERATE(0, 1, 2, 3, 9); + + auto result = v1.Search(request); + size_t expectedCount = allFiltered ? 0 : (request.MaximumResults ? std::min(size_t{ 3 }, request.MaximumResults) : 3); + REQUIRE(result.Matches.size() == expectedCount); + REQUIRE(result.Truncated == (!allFiltered && expectedCount < 3)); + REQUIRE(requestCount == (!allFiltered && request.MaximumResults == 1 ? size_t{ 2 } : size_t{ 3 })); + REQUIRE(continuationTokens[0].empty()); + REQUIRE(continuationTokens[1] == L"next"); + if (requestCount == 3) + { + REQUIRE(continuationTokens[2] == L"last"); + } + const std::vector expectedIds{ "Match.One", "Match.Two", "Match.Three" }; + for (size_t i = 0; i < expectedCount; ++i) + { + REQUIRE(result.Matches[i].PackageInformation.PackageIdentifier == expectedIds[i]); + } +} + TEST_CASE("Search_ContinuationToken", "[RestSource][Interface_1_0]") { utility::string_t sample = _XPLATSTR( @@ -462,7 +668,7 @@ TEST_CASE("Search_Optimized_ManifestResponse", "[RestSource][Interface_1_0]") utility::string_t sample = GetGoodManifest_RequiredFields(); HttpClientHelper helper{ GetTestRestRequestHandler(web::http::status_codes::OK, std::move(sample)) }; AppInstaller::Repository::SearchRequest request; - PackageMatchFilter filter{ PackageMatchField::Id, MatchType::Exact, "Foo" }; + PackageMatchFilter filter{ PackageMatchField::Id, MatchType::Exact, "Foo.Bar" }; request.Filters.emplace_back(std::move(filter)); Interface v1{ TestRestUriString, std::move(helper) }; Schema::IRestClient::SearchResult result = v1.Search(request); @@ -514,12 +720,36 @@ TEST_CASE("Search_Optimized_NoResponse_NotFoundCode", "[RestSource][Interface_1_ REQUIRE_THROWS_HR(v1.Search(request), APPINSTALLER_CLI_ERROR_RESTAPI_ENDPOINT_NOT_FOUND); } +TEST_CASE("Search_Optimized_ExplicitIdFilter", "[RestSource][Interface_1_0]") +{ + HttpClientHelper helper{ GetTestRestRequestHandler(web::http::status_codes::OK, GetGoodManifest_RequiredFields()) }; + Interface v1{ TestRestUriString, helper }; + SearchRequest request; + auto type = GENERATE(MatchType::Exact, MatchType::CaseInsensitive); + std::string id = GENERATE("Foo.Bar", "foo.bar", "Foo", "Other.Package"); + request.Filters.emplace_back(PackageMatchField::Id, type, id); + + auto result = v1.Search(request); + bool expected = id == "Foo.Bar" || (type == MatchType::CaseInsensitive && id == "foo.bar"); + REQUIRE(result.Matches.size() == (expected ? size_t{ 1 } : size_t{ 0 })); + REQUIRE_FALSE(result.Truncated); + if (expected) + { + REQUIRE(result.Matches[0].PackageInformation.PackageIdentifier == "Foo.Bar"); + REQUIRE(result.Matches[0].Versions[0].Manifest.has_value()); + } +} + TEST_CASE("Search_SubstringIdFallback_ManifestResponse", "[RestSource][Interface_1_0]") { - utility::string_t emptySearchResponse = _XPLATSTR(R"delimiter({ "Data" : [] })delimiter"); + bool filteredSearch = GENERATE(false, true); + bool manifestMatches = GENERATE(false, true); + auto searchResponse = filteredSearch ? GetSearchResponse_PackageIds({ L"Unrelated.Package" }) : GetSearchResponse_PackageIds({}); + size_t searchCount = 0; + size_t manifestCount = 0; auto handler = std::make_shared( - [emptySearchResponse](web::http::http_request request) -> pplx::task + [&](web::http::http_request request) -> pplx::task { web::http::http_response response; response.headers().set_content_type(web::http::details::mime_types::application_json); @@ -527,11 +757,13 @@ TEST_CASE("Search_SubstringIdFallback_ManifestResponse", "[RestSource][Interface if (request.method() == web::http::methods::POST) { + ++searchCount; response.set_status_code(web::http::status_codes::OK); - response.set_body(web::json::value::parse(emptySearchResponse)); + response.set_body(web::json::value::parse(searchResponse)); } else if (request.method() == web::http::methods::GET) { + ++manifestCount; response.set_status_code(web::http::status_codes::OK); response.set_body(web::json::value::parse(GetGoodManifest_RequiredFields())); } @@ -545,15 +777,21 @@ TEST_CASE("Search_SubstringIdFallback_ManifestResponse", "[RestSource][Interface HttpClientHelper helper{ std::move(handler) }; AppInstaller::Repository::SearchRequest request; - request.Filters.emplace_back(PackageMatchFilter{ PackageMatchField::Id, MatchType::Substring, "Foo.Bar" }); + std::string_view id = manifestMatches ? "Foo.Bar" : "Other.Id"; + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Substring, id); Interface v1{ TestRestUriString, std::move(helper) }; Schema::IRestClient::SearchResult result = v1.Search(request); - REQUIRE(result.Matches.size() == 1); - REQUIRE(result.Matches[0].PackageInformation.PackageIdentifier == "Foo.Bar"); - REQUIRE(result.Matches[0].Versions.size() == 1); - REQUIRE(result.Matches[0].Versions[0].VersionAndChannel.GetVersion().ToString() == "5.0.0"); - REQUIRE(result.Matches[0].Versions[0].Manifest.has_value()); + REQUIRE(searchCount == 1); + REQUIRE(manifestCount == 1); + REQUIRE(result.Matches.size() == (manifestMatches ? size_t{ 1 } : size_t{ 0 })); + if (manifestMatches) + { + REQUIRE(result.Matches[0].PackageInformation.PackageIdentifier == "Foo.Bar"); + REQUIRE(result.Matches[0].Versions.size() == 1); + REQUIRE(result.Matches[0].Versions[0].VersionAndChannel.GetVersion().ToString() == "5.0.0"); + REQUIRE(result.Matches[0].Versions[0].Manifest.has_value()); + } } TEST_CASE("Search_SubstringId_NoFallbackWhenSearchMatches", "[RestSource][Interface_1_0]") diff --git a/src/AppInstallerCLITests/RestInterface_1_1.cpp b/src/AppInstallerCLITests/RestInterface_1_1.cpp index eaefeda053..3e71394d87 100644 --- a/src/AppInstallerCLITests/RestInterface_1_1.cpp +++ b/src/AppInstallerCLITests/RestInterface_1_1.cpp @@ -403,8 +403,26 @@ TEST_CASE("Search_GoodRequest_OnlyMarketRequired", "[RestSource][Interface_1_1]" AppInstaller::Repository::SearchRequest request; PackageMatchFilter filter{ PackageMatchField::Name, MatchType::Exact, "Foo" }; request.Filters.emplace_back(std::move(filter)); + size_t expectedCount = 1; + + SECTION("Name filter") {} + SECTION("Matching ID filter") + { + request.Filters.emplace_back(PackageMatchField::Id, MatchType::CaseInsensitive, "GIT.PACKAGE"); + } + SECTION("Mismatching ID filter") + { + request.Filters.emplace_back(PackageMatchField::Id, MatchType::CaseInsensitive, "Other.Package"); + expectedCount = 0; + } + Schema::IRestClient::SearchResult searchResponse = v1_1.Search(request); - REQUIRE(searchResponse.Matches.size() == 1); + REQUIRE(searchResponse.Matches.size() == expectedCount); + if (!expectedCount) + { + return; + } + Schema::IRestClient::Package package = searchResponse.Matches.at(0); REQUIRE(package.PackageInformation.PackageIdentifier.compare("git.package") == 0); REQUIRE(package.PackageInformation.Publisher.compare("git") == 0); diff --git a/src/AppInstallerCLITests/Strings.cpp b/src/AppInstallerCLITests/Strings.cpp index 86f052cb31..5d79c23c73 100644 --- a/src/AppInstallerCLITests/Strings.cpp +++ b/src/AppInstallerCLITests/Strings.cpp @@ -136,6 +136,25 @@ TEST_CASE("CaseInsensitiveStartsWith", "[strings]") REQUIRE(!CaseInsensitiveStartsWith(" starts", "starts")); } +TEST_CASE("ICUCaseInsensitiveStartsWith", "[strings]") +{ + REQUIRE(ICUCaseInsensitiveStartsWith("startswith", "starts")); + REQUIRE(ICUCaseInsensitiveStartsWith("startswith", "STAR")); + REQUIRE(ICUCaseInsensitiveStartsWith("startswith", "STARTSWITH")); + REQUIRE(ICUCaseInsensitiveStartsWith("startswith", "")); + REQUIRE(ICUCaseInsensitiveStartsWith("", "")); + REQUIRE_FALSE(ICUCaseInsensitiveStartsWith("starts", "startswith")); + REQUIRE_FALSE(ICUCaseInsensitiveStartsWith("", "starts")); + REQUIRE_FALSE(ICUCaseInsensitiveStartsWith("withstarts", "starts")); + REQUIRE_FALSE(ICUCaseInsensitiveStartsWith(" starts", "starts")); + + REQUIRE(ICUCaseInsensitiveStartsWith(u8"Vendor.\u1E9EApp", u8"vendor.\u00DF")); + REQUIRE(ICUCaseInsensitiveStartsWith(u8"Vendor.\u00DFApp", u8"vendor.\u1E9E")); + REQUIRE(ICUCaseInsensitiveStartsWith(u8"Stra\u00DFe", "STRASSE")); + REQUIRE(ICUCaseInsensitiveStartsWith("STRASSE", u8"stra\u00DFe")); + REQUIRE_FALSE(ICUCaseInsensitiveStartsWith(u8"Vendor.\u00DFApp", "vendor.st")); +} + TEST_CASE("FoldCase", "[strings]") { REQUIRE(FoldCase(""sv) == FoldCase(""sv)); diff --git a/src/AppInstallerRepositoryCore/MatchCriteriaResolver.cpp b/src/AppInstallerRepositoryCore/MatchCriteriaResolver.cpp index 46d7fd72b5..2493f564d6 100644 --- a/src/AppInstallerRepositoryCore/MatchCriteriaResolver.cpp +++ b/src/AppInstallerRepositoryCore/MatchCriteriaResolver.cpp @@ -9,11 +9,6 @@ namespace AppInstaller::Repository { using ValueMatchFunction = bool (*)(const Utility::NormalizedString&, const Utility::NormalizedString&); - bool ValueMatchFunction_AlwaysFalse(const Utility::NormalizedString&, const Utility::NormalizedString&) - { - return false; - } - bool ValueMatchFunction_Exact(const Utility::NormalizedString& a, const Utility::NormalizedString& b) { return a == b; @@ -50,7 +45,7 @@ namespace AppInstaller::Repository case MatchType::FuzzySubstring: case MatchType::Wildcard: default: - return ValueMatchFunction_AlwaysFalse; + return nullptr; } } @@ -169,6 +164,16 @@ namespace AppInstaller::Repository } } + std::optional MatchesRequest(const RequestMatch& request, const Utility::NormalizedString& value) + { + if (auto matchFunction = GetMatchTypeFunction(request.Type)) + { + return matchFunction(value, request.Value); + } + + return std::nullopt; + } + PackageMatchFilter FindBestMatchCriteria(const SearchRequest& request, const IPackageVersion* packageVersion) { PackageMatchFilter result{ PackageMatchField::Unknown, MatchType::Wildcard }; diff --git a/src/AppInstallerRepositoryCore/MatchCriteriaResolver.h b/src/AppInstallerRepositoryCore/MatchCriteriaResolver.h index 6ad225275f..168559baf3 100644 --- a/src/AppInstallerRepositoryCore/MatchCriteriaResolver.h +++ b/src/AppInstallerRepositoryCore/MatchCriteriaResolver.h @@ -5,6 +5,9 @@ namespace AppInstaller::Repository { + // Returns whether the value matches, or nullopt if the match type cannot be evaluated locally. + std::optional MatchesRequest(const RequestMatch& request, const Utility::NormalizedString& value); + // Finds the highest rated match criteria for the package based on the search request, PackageMatchFilter FindBestMatchCriteria(const SearchRequest& request, const IPackageVersion* packageVersion); } diff --git a/src/AppInstallerRepositoryCore/Rest/Schema/1_0/RestInterface_1_0.cpp b/src/AppInstallerRepositoryCore/Rest/Schema/1_0/RestInterface_1_0.cpp index 5a9fb399e7..d3eb4a87e1 100644 --- a/src/AppInstallerRepositoryCore/Rest/Schema/1_0/RestInterface_1_0.cpp +++ b/src/AppInstallerRepositoryCore/Rest/Schema/1_0/RestInterface_1_0.cpp @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. #include "pch.h" +#include "MatchCriteriaResolver.h" #include "Rest/Schema/1_0/Interface.h" #include "Rest/Schema/IRestClient.h" #include @@ -22,6 +23,32 @@ namespace AppInstaller::Repository::Rest::Schema::V1_0 constexpr std::string_view VersionQueryParam = "Version"sv; constexpr std::string_view ChannelQueryParam = "Channel"sv; + void FilterSearchResult(const SearchRequest& request, IRestClient::SearchResult& result) + { + for (const auto& filter : request.Filters) + { + // Other fields may match metadata omitted from the search response, such as localized names. + if (filter.Field != PackageMatchField::Id) + { + continue; + } + + auto& matches = result.Matches; + matches.erase(std::remove_if(matches.begin(), matches.end(), [&](const IRestClient::Package& package) + { + auto match = MatchesRequest(filter, package.PackageInformation.PackageIdentifier); + if (match && !match.value()) + { + AICLI_LOG(Repo, Verbose, << "Discarding REST package " << package.PackageInformation.PackageIdentifier << + ": does not match ID filter '" << filter.Value << "' [" << ToString(filter.Type) << "]"); + return true; + } + + return false; + }), matches.end()); + } + } + utility::string_t GetSearchEndpoint(const std::string& restApiUri) { return AppInstaller::Rest::AppendPathToUri(AppInstaller::JSON::GetUtilityString(restApiUri), AppInstaller::JSON::GetUtilityString(ManifestSearchPostEndpoint)); @@ -134,6 +161,7 @@ namespace AppInstaller::Repository::Rest::Schema::V1_0 if (jsonObject) { SearchResult currentResult = GetSearchResult(jsonObject.value()); + FilterSearchResult(request, currentResult); size_t insertElements = !request.MaximumResults ? currentResult.Matches.size() : std::min(currentResult.Matches.size(), request.MaximumResults - results.Matches.size()); @@ -250,6 +278,7 @@ namespace AppInstaller::Repository::Rest::Schema::V1_0 searchResult.Matches.emplace_back(std::move(package)); } + FilterSearchResult(request, searchResult); return searchResult; } diff --git a/src/AppInstallerSharedLib/AppInstallerStrings.cpp b/src/AppInstallerSharedLib/AppInstallerStrings.cpp index f1ee1b359b..e5dc5163e1 100644 --- a/src/AppInstallerSharedLib/AppInstallerStrings.cpp +++ b/src/AppInstallerSharedLib/AppInstallerStrings.cpp @@ -217,7 +217,9 @@ namespace AppInstaller::Utility bool ICUCaseInsensitiveStartsWith(std::string_view a, std::string_view b) { - return a.length() >= b.length() && ICUCaseInsensitiveEquals(a.substr(0, b.length()), b); + const auto foldedA = FoldCase(a); + const auto foldedB = FoldCase(b); + return foldedA.compare(0, foldedB.size(), foldedB) == 0; } std::string ConvertToUTF8(std::wstring_view input)