From a471e287a94f28662252f797ae61737b4ee64e01 Mon Sep 17 00:00:00 2001 From: yao-msft <50888816+yao-msft@users.noreply.github.com> Date: Thu, 14 Dec 2023 13:35:11 -0800 Subject: [PATCH] Allow winget configure from https location and extend winget configure validate for winget resource units (#3833) --- azure-pipelines.yml | 2 +- src/AppInstallerCLI.sln | 1 + .../AppInstallerCLICore.vcxproj | 2 + .../Commands/ConfigureCommand.cpp | 2 +- .../Commands/ConfigureShowCommand.cpp | 2 +- .../Commands/ConfigureTestCommand.cpp | 2 +- .../Commands/ConfigureValidateCommand.cpp | 3 +- ...igurationWingetDscModuleUnitValidation.cpp | 396 ++++++++++++++++++ ...nfigurationWingetDscModuleUnitValidation.h | 30 ++ src/AppInstallerCLICore/Resources.h | 17 + .../Workflows/ConfigurationFlow.cpp | 126 +++++- .../Workflows/ConfigurationFlow.h | 6 + .../Workflows/WorkflowBase.cpp | 44 ++ .../Workflows/WorkflowBase.h | 16 + .../AppInstallerCLIE2ETests.csproj | 17 - .../ConfigureCommand.cs | 12 + .../ConfigureShowCommand.cs | 11 + .../ConfigureTestCommand.cs | 11 + .../ConfigureValidateCommand.cs | 106 +++++ ...sourceValidate_DependencySourceMissing.yml | 13 + .../WinGetDscResourceValidate_Good.yml | 24 ++ ...GetDscResourceValidate_PackageNotFound.yml | 24 ++ ...esourceValidate_PackageVersionNotFound.yml | 24 ++ ...etDscResourceValidate_SourceOpenFailed.yml | 24 ++ ...onSpecifiedWithOnlyOneVersionAvailable.yml | 24 ++ ...Validate_VersionSpecifiedWithUseLatest.yml | 25 ++ .../Shared/Strings/en-us/winget.resw | 70 +++- .../Public/AppInstallerDownloader.h | 1 + src/LocalhostWebServer/Program.cs | 41 +- .../Run-LocalhostWebServer.ps1 | 7 +- src/LocalhostWebServer/Startup.cs | 3 + .../Common/WinGetIntegrity.cs | 2 +- 32 files changed, 1050 insertions(+), 38 deletions(-) create mode 100644 src/AppInstallerCLICore/ConfigurationWingetDscModuleUnitValidation.cpp create mode 100644 src/AppInstallerCLICore/ConfigurationWingetDscModuleUnitValidation.h create mode 100644 src/AppInstallerCLIE2ETests/TestData/Configuration/WinGetDscResourceValidate_DependencySourceMissing.yml create mode 100644 src/AppInstallerCLIE2ETests/TestData/Configuration/WinGetDscResourceValidate_Good.yml create mode 100644 src/AppInstallerCLIE2ETests/TestData/Configuration/WinGetDscResourceValidate_PackageNotFound.yml create mode 100644 src/AppInstallerCLIE2ETests/TestData/Configuration/WinGetDscResourceValidate_PackageVersionNotFound.yml create mode 100644 src/AppInstallerCLIE2ETests/TestData/Configuration/WinGetDscResourceValidate_SourceOpenFailed.yml create mode 100644 src/AppInstallerCLIE2ETests/TestData/Configuration/WinGetDscResourceValidate_VersionSpecifiedWithOnlyOneVersionAvailable.yml create mode 100644 src/AppInstallerCLIE2ETests/TestData/Configuration/WinGetDscResourceValidate_VersionSpecifiedWithUseLatest.yml diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 829c86206a..0c01ebb798 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -222,7 +222,7 @@ jobs: - template: templates/e2e-setup.yml parameters: sourceDir: $(Build.SourcesDirectory) - localhostWebServerArgs: '-BuildRoot $(buildOutDir)\LocalhostWebServer -StaticFileRoot $(Agent.TempDirectory)\TestLocalIndex -LocalSourceJson $(Build.SourcesDirectory)\src\AppInstallerCLIE2ETests\TestData\localsource.json -SourceCert $(Build.SourcesDirectory)\src\AppInstallerCLIE2ETests\TestData\AppInstallerTest.cer' + localhostWebServerArgs: '-BuildRoot $(buildOutDir)\LocalhostWebServer -StaticFileRoot $(Agent.TempDirectory)\TestLocalIndex -LocalSourceJson $(Build.SourcesDirectory)\src\AppInstallerCLIE2ETests\TestData\localsource.json -TestDataPath $(Build.SourcesDirectory)\src\AppInstallerCLIE2ETests\TestData -SourceCert $(Build.SourcesDirectory)\src\AppInstallerCLIE2ETests\TestData\AppInstallerTest.cer' - template: templates/e2e-test.template.yml parameters: diff --git a/src/AppInstallerCLI.sln b/src/AppInstallerCLI.sln index 138174f68e..25ff8cbf9e 100644 --- a/src/AppInstallerCLI.sln +++ b/src/AppInstallerCLI.sln @@ -147,6 +147,7 @@ Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Detours", "Xlang\UndockedRe EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "templates", "templates", "{8E43F982-40D5-4DF1-9044-C08047B5F43B}" ProjectSection(SolutionItems) = preProject + ..\templates\e2e-setup.yml = ..\templates\e2e-setup.yml ..\templates\e2e-test.template.yml = ..\templates\e2e-test.template.yml EndProjectSection EndProject diff --git a/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj b/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj index 11d44ba3a9..9a94874dff 100644 --- a/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj +++ b/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj @@ -382,6 +382,7 @@ + @@ -440,6 +441,7 @@ + diff --git a/src/AppInstallerCLICore/Commands/ConfigureCommand.cpp b/src/AppInstallerCLICore/Commands/ConfigureCommand.cpp index ba5fff1857..3b8f6daed6 100644 --- a/src/AppInstallerCLICore/Commands/ConfigureCommand.cpp +++ b/src/AppInstallerCLICore/Commands/ConfigureCommand.cpp @@ -71,7 +71,7 @@ namespace AppInstaller::CLI { context << VerifyIsFullPackage << - VerifyFile(Execution::Args::Type::ConfigurationFile) << + VerifyFileOrUri(Execution::Args::Type::ConfigurationFile) << CreateConfigurationProcessor << OpenConfigurationSet << ShowConfigurationSet << diff --git a/src/AppInstallerCLICore/Commands/ConfigureShowCommand.cpp b/src/AppInstallerCLICore/Commands/ConfigureShowCommand.cpp index 284a7654e1..c575cc349a 100644 --- a/src/AppInstallerCLICore/Commands/ConfigureShowCommand.cpp +++ b/src/AppInstallerCLICore/Commands/ConfigureShowCommand.cpp @@ -37,7 +37,7 @@ namespace AppInstaller::CLI void ConfigureShowCommand::ExecuteInternal(Execution::Context& context) const { context << - VerifyFile(Execution::Args::Type::ConfigurationFile) << + VerifyFileOrUri(Execution::Args::Type::ConfigurationFile) << CreateConfigurationProcessor << OpenConfigurationSet << ShowConfigurationSet; diff --git a/src/AppInstallerCLICore/Commands/ConfigureTestCommand.cpp b/src/AppInstallerCLICore/Commands/ConfigureTestCommand.cpp index 63e8d56428..bfc53f78a9 100644 --- a/src/AppInstallerCLICore/Commands/ConfigureTestCommand.cpp +++ b/src/AppInstallerCLICore/Commands/ConfigureTestCommand.cpp @@ -38,7 +38,7 @@ namespace AppInstaller::CLI { context << VerifyIsFullPackage << - VerifyFile(Execution::Args::Type::ConfigurationFile) << + VerifyFileOrUri(Execution::Args::Type::ConfigurationFile) << CreateConfigurationProcessor << OpenConfigurationSet << ShowConfigurationSet << diff --git a/src/AppInstallerCLICore/Commands/ConfigureValidateCommand.cpp b/src/AppInstallerCLICore/Commands/ConfigureValidateCommand.cpp index 83b17e5d91..024b450e7a 100644 --- a/src/AppInstallerCLICore/Commands/ConfigureValidateCommand.cpp +++ b/src/AppInstallerCLICore/Commands/ConfigureValidateCommand.cpp @@ -37,11 +37,12 @@ namespace AppInstaller::CLI { context << VerifyIsFullPackage << - VerifyFile(Execution::Args::Type::ConfigurationFile) << + VerifyFileOrUri(Execution::Args::Type::ConfigurationFile) << CreateConfigurationProcessor << OpenConfigurationSet << ValidateConfigurationSetSemantics << ValidateConfigurationSetUnitProcessors << + ValidateConfigurationSetUnitContents << ValidateAllGoodMessage; } diff --git a/src/AppInstallerCLICore/ConfigurationWingetDscModuleUnitValidation.cpp b/src/AppInstallerCLICore/ConfigurationWingetDscModuleUnitValidation.cpp new file mode 100644 index 0000000000..1e556b888c --- /dev/null +++ b/src/AppInstallerCLICore/ConfigurationWingetDscModuleUnitValidation.cpp @@ -0,0 +1,396 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#include "pch.h" +#include "ConfigurationWingetDscModuleUnitValidation.h" +#include "ExecutionContext.h" +#include + +using namespace winrt::Microsoft::Management::Configuration; +using namespace winrt::Windows::Foundation; +using namespace winrt::Windows::Foundation::Collections; +using namespace AppInstaller::Utility::literals; + +namespace AppInstaller::CLI::Configuration +{ + namespace + { + constexpr static std::string_view UnitType_WinGetSources = "WinGetSources"sv; + constexpr static std::string_view UnitType_WinGetPackage = "WinGetPackage"sv; + + constexpr static std::string_view WellKnownSourceName_WinGet = "winget"sv; + constexpr static std::string_view WellKnownSourceName_MSStore = "msstore"sv; + + constexpr static std::string_view ValueSetKey_TreatAsArray = "treatAsArray"sv; + + constexpr static std::string_view WinGetSourcesValueSetKey_Sources = "sources"sv; + constexpr static std::string_view WinGetSourcesValueSetKey_SourceName = "name"sv; + constexpr static std::string_view WinGetSourcesValueSetKey_SourceType = "type"sv; + constexpr static std::string_view WinGetSourcesValueSetKey_SourceArg = "arg"sv; + + constexpr static std::string_view WinGetPackageValueSetKey_Id = "id"sv; + constexpr static std::string_view WinGetPackageValueSetKey_Version = "version"sv; + constexpr static std::string_view WinGetPackageValueSetKey_Source = "source"sv; + constexpr static std::string_view WinGetPackageValueSetKey_UseLatest = "useLatest"sv; + + struct WinGetSource + { + std::string Name; + std::string Type; + std::string Arg; + + bool Empty() + { + return Name.empty() && Arg.empty() && Type.empty(); + } + }; + + std::string GetPropertyValueAsString(const winrt::Windows::Foundation::IInspectable& value) + { + IPropertyValue propertyValue = value.try_as(); + if (propertyValue && propertyValue.Type() == PropertyType::String) + { + return Utility::ConvertToUTF8(propertyValue.GetString()); + } + + return {}; + } + + bool GetPropertyValueAsBoolean(const winrt::Windows::Foundation::IInspectable& value, bool defaultIfFailed = false) + { + IPropertyValue propertyValue = value.try_as(); + if (propertyValue && propertyValue.Type() == PropertyType::Boolean) + { + return propertyValue.GetBoolean(); + } + + return defaultIfFailed; + } + + std::vector ParseWinGetSourcesFromSettings(const ValueSet& settings) + { + // Iterate through the value set as Powershell variables are case insensitive. + std::vector result; + for (auto const& settingsPair : settings) + { + auto settingsKey = Utility::ConvertToUTF8(settingsPair.Key()); + if (Utility::CaseInsensitiveEquals(WinGetSourcesValueSetKey_Sources, settingsKey)) + { + auto sources = settingsPair.Value().try_as(); + if (!sources) + { + return {}; + } + bool isArray = false; + for (auto const& sourcesPair : sources) + { + if (Utility::CaseInsensitiveEquals(ValueSetKey_TreatAsArray, Utility::ConvertToUTF8(sourcesPair.Key()))) + { + isArray = true; + } + else + { + auto source = sourcesPair.Value().try_as(); + if (source) + { + WinGetSource wingetSource; + for (auto const& sourcePair : source) + { + auto sourceKey = Utility::ConvertToUTF8(sourcePair.Key()); + if (Utility::CaseInsensitiveEquals(WinGetSourcesValueSetKey_SourceName, sourceKey)) + { + wingetSource.Name = GetPropertyValueAsString(sourcePair.Value()); + } + else if (Utility::CaseInsensitiveEquals(WinGetSourcesValueSetKey_SourceType, sourceKey)) + { + wingetSource.Type = GetPropertyValueAsString(sourcePair.Value()); + } + else if (Utility::CaseInsensitiveEquals(WinGetSourcesValueSetKey_SourceArg, sourceKey)) + { + wingetSource.Arg = GetPropertyValueAsString(sourcePair.Value()); + } + } + + if (!wingetSource.Empty()) + { + result.emplace_back(std::move(wingetSource)); + } + } + } + } + + if (!isArray) + { + return {}; + } + + break; + } + } + + return result; + } + + bool IsWellKnownSourceName(std::string_view sourceName) + { + return Utility::CaseInsensitiveEquals(WellKnownSourceName_WinGet, sourceName) || + Utility::CaseInsensitiveEquals(WellKnownSourceName_MSStore, sourceName); + } + + bool ValidateWellKnownSource(const WinGetSource& source) + { + static std::vector wellKnownSourceDetails = + { + Repository::Source{ Repository::WellKnownSource::WinGet }.GetDetails(), + Repository::Source{ Repository::WellKnownSource::MicrosoftStore }.GetDetails(), + }; + + for (auto const& wellKnownSource : wellKnownSourceDetails) + { + if (Utility::CaseInsensitiveEquals(wellKnownSource.Name, source.Name) && + Utility::CaseInsensitiveEquals(wellKnownSource.Arg, source.Arg) && + Utility::CaseInsensitiveEquals(wellKnownSource.Type, source.Type)) + { + return true; + } + } + + return false; + } + + struct WinGetPackage + { + std::string Id; + std::string Version; + std::string Source; + bool UseLatest = false; + + bool Empty() + { + return Id.empty() && Version.empty() && Source.empty(); + } + }; + + WinGetPackage ParseWinGetPackageFromSettings(const ValueSet& settings) + { + // Iterate through the value set as Powershell variables are case insensitive. + WinGetPackage result; + for (auto const& settingsPair : settings) + { + auto settingsKey = Utility::ConvertToUTF8(settingsPair.Key()); + if (Utility::CaseInsensitiveEquals(WinGetPackageValueSetKey_Id, settingsKey)) + { + result.Id = GetPropertyValueAsString(settingsPair.Value()); + } + else if (Utility::CaseInsensitiveEquals(WinGetPackageValueSetKey_Version, settingsKey)) + { + result.Version = GetPropertyValueAsString(settingsPair.Value()); + } + else if (Utility::CaseInsensitiveEquals(WinGetPackageValueSetKey_Source, settingsKey)) + { + result.Source = GetPropertyValueAsString(settingsPair.Value()); + } + else if (Utility::CaseInsensitiveEquals(WinGetPackageValueSetKey_UseLatest, settingsKey)) + { + result.UseLatest = GetPropertyValueAsBoolean(settingsPair.Value()); + } + } + + return result; + } + } + + bool WingetDscModuleUnitValidator::ValidateConfigurationSetUnit(Execution::Context& context, const ConfigurationUnit& unit) + { + bool foundIssues = false; + auto details = unit.Details(); + auto unitType = Utility::ConvertToUTF8(details.UnitType()); + auto unitIntent = unit.Intent(); + + if (Utility::CaseInsensitiveEquals(UnitType_WinGetSources, unitType)) + { + auto sources = ParseWinGetSourcesFromSettings(unit.Settings()); + if (sources.size() == 0) + { + AICLI_LOG(Config, Warning, << "Failed to parse WinGetSources or empty content."); + context.Reporter.Warn() << Resource::String::WinGetResourceUnitEmptyContent(Utility::LocIndView{ UnitType_WinGetSources }) << std::endl; + foundIssues = true; + } + for (auto const& source : sources) + { + // Validate basic semantics. + if (source.Name.empty()) + { + AICLI_LOG(Config, Error, << "WinGetSource unit missing required arg: Name"); + context.Reporter.Error() << Resource::String::WinGetResourceUnitMissingRequiredArg(Utility::LocIndView{ UnitType_WinGetSources }, "Name"_liv) << std::endl; + foundIssues = true; + } + if (source.Arg.empty()) + { + AICLI_LOG(Config, Error, << "WinGetSource unit missing required arg: Arg"); + context.Reporter.Error() << Resource::String::WinGetResourceUnitMissingRequiredArg(Utility::LocIndView{ UnitType_WinGetSources }, "Arg"_liv) << std::endl; + foundIssues = true; + } + + // Validate well known source or process 3rd party source. + if (IsWellKnownSourceName(source.Name)) + { + if (!ValidateWellKnownSource(source)) + { + AICLI_LOG(Config, Warning, << "WinGetSource conflicts with a well known source. Source: " << source.Name); + context.Reporter.Warn() << Resource::String::WinGetResourceUnitKnownSourceConfliction(Utility::LocIndView{ source.Name }) << std::endl; + foundIssues = true; + } + } + else + { + if (unitIntent == ConfigurationUnitIntent::Assert) + { + AICLI_LOG(Config, Warning, << "Asserting on 3rd party source: " << source.Name); + context.Reporter.Warn() << Resource::String::WinGetResourceUnitThirdPartySourceAssertion(Utility::LocIndView{ source.Name }) << std::endl; + foundIssues = true; + } + else if (unitIntent == ConfigurationUnitIntent::Apply) + { + // Add to dependency source map so it can be validated with later WinGetPackage source + m_dependenciesSourceAndUnitIdMap.emplace(Utility::FoldCase(std::string_view{ source.Name }), Utility::FoldCase(Utility::NormalizedString{ unit.Identifier() })); + } + } + } + } + else if (Utility::CaseInsensitiveEquals(UnitType_WinGetPackage, unitType)) + { + auto package = ParseWinGetPackageFromSettings(unit.Settings()); + if (package.Empty()) + { + AICLI_LOG(Config, Warning, << "Failed to parse WinGetPackage or empty content."); + context.Reporter.Warn() << Resource::String::WinGetResourceUnitEmptyContent(Utility::LocIndView{ UnitType_WinGetPackage }) << std::endl; + foundIssues = true; + } + // Validate basic semantics. + if (package.Id.empty()) + { + AICLI_LOG(Config, Error, << "WinGetPackage unit missing required arg: Id"); + context.Reporter.Error() << Resource::String::WinGetResourceUnitMissingRequiredArg(Utility::LocIndView{ UnitType_WinGetPackage }, "Id"_liv) << std::endl; + foundIssues = true; + } + if (package.Source.empty()) + { + AICLI_LOG(Config, Warning, << "WinGetPackage unit missing recommended arg: Source"); + context.Reporter.Warn() << Resource::String::WinGetResourceUnitMissingRecommendedArg(Utility::LocIndView{ UnitType_WinGetPackage }, "Source"_liv) << std::endl; + foundIssues = true; + } + if (package.UseLatest && !package.Version.empty()) + { + AICLI_LOG(Config, Error, << "WinGetPackage unit both UseLatest and Version declared. Package: " << package.Id); + context.Reporter.Error() << Resource::String::WinGetResourceUnitBothPackageVersionAndUseLatest(Utility::LocIndView{ package.Id }) << std::endl; + foundIssues = true; + } + // Validate dependency source is configured. + if (!package.Source.empty() && !IsWellKnownSourceName(package.Source)) + { + if (unitIntent == ConfigurationUnitIntent::Assert) + { + AICLI_LOG(Config, Warning, << "Asserting on a package that depends on a 3rd party source. Package: " << package.Id << " Source: " << package.Source); + context.Reporter.Warn() << Resource::String::WinGetResourceUnitThirdPartySourceAssertionForPackage(Utility::LocIndView{ package.Id }, Utility::LocIndView{ package.Source }) << std::endl; + foundIssues = true; + } + else + { + auto dependencySourceItr = m_dependenciesSourceAndUnitIdMap.find(Utility::FoldCase(std::string_view{ package.Source })); + if (dependencySourceItr == m_dependenciesSourceAndUnitIdMap.end()) + { + AICLI_LOG(Config, Warning, << "WinGetPackage depends on a 3rd party source not previously configured. Package: " << package.Id << " Source: " << package.Source); + context.Reporter.Warn() << Resource::String::WinGetResourceUnitDependencySourceNotConfigured(Utility::LocIndView{ package.Id }, Utility::LocIndView{ package.Source }) << std::endl; + foundIssues = true; + } + else + { + bool foundInUnitDependencies = false; + for (auto const& entry : unit.Dependencies()) + { + // The map contains normalized string, so just use direct comparison; + if (dependencySourceItr->second == Utility::FoldCase(Utility::NormalizedString{ entry })) + { + foundInUnitDependencies = true; + break; + } + } + if (!foundInUnitDependencies) + { + AICLI_LOG(Config, Warning, << "WinGetPackage depends on a 3rd party source. It is recommended to add the WinGetSources unit configuring the source to the unit's dependsOn list. Package: " << package.Id << " Source: " << package.Source); + context.Reporter.Warn() << Resource::String::WinGetResourceUnitDependencySourceNotDeclaredAsDependency(Utility::LocIndView{ package.Id }, Utility::LocIndView{ package.Source }) << std::endl; + foundIssues = true; + } + } + } + } + // Validate package is found and version available. + try + { + Repository::Source source{ package.Source }; + if (!source) + { + AICLI_LOG(Config, Warning, << "Failed to open WinGet source. Package: " << package.Id << " Source: " << package.Source); + context.Reporter.Warn() << Resource::String::WinGetResourceUnitFailedToValidatePackageSourceOpenFailed(Utility::LocIndView{ package.Id }, Utility::LocIndView{ package.Source }) << std::endl; + foundIssues = true; + } + else + { + ProgressCallback empty; + source.Open(empty); + Repository::SearchRequest searchRequest; + searchRequest.Filters.emplace_back(Repository::PackageMatchFilter{ Repository::PackageMatchField::Id, Repository::MatchType::CaseInsensitive, package.Id }); + auto searchResult = source.Search(searchRequest); + if (searchResult.Matches.size() == 0) + { + AICLI_LOG(Config, Warning, << "WinGetPackage not found: " << package.Id); + context.Reporter.Warn() << Resource::String::WinGetResourceUnitFailedToValidatePackageNotFound(Utility::LocIndView{ package.Id }) << std::endl; + foundIssues = true; + } + else if (searchResult.Matches.size() > 1) + { + AICLI_LOG(Config, Warning, << "More than one WinGetPackage found: " << package.Id); + context.Reporter.Warn() << Resource::String::WinGetResourceUnitFailedToValidatePackageMultipleFound(Utility::LocIndView{ package.Id }) << std::endl; + foundIssues = true; + } + else + { + if (!package.Version.empty()) + { + auto versionKeys = searchResult.Matches.at(0).Package->GetAvailableVersionKeys(Repository::PinBehavior::IgnorePins); + bool foundVersion = false; + for (auto const& versionKey : versionKeys) + { + if (versionKey.Version == Utility::NormalizedString(package.Version)) + { + foundVersion = true; + break; + } + } + if (!foundVersion) + { + AICLI_LOG(Config, Warning, << "WinGetPackage version not found. Package: " << package.Id << " Version: " << package.Version); + context.Reporter.Warn() << Resource::String::WinGetResourceUnitFailedToValidatePackageVersionNotFound(Utility::LocIndView{ package.Id }, Utility::LocIndView{ package.Version }) << std::endl; + foundIssues = true; + } + if (versionKeys.size() == 1) + { + AICLI_LOG(Config, Warning, << "WinGetPackage version specified with only one version available: " << package.Id); + context.Reporter.Warn() << Resource::String::WinGetResourceUnitPackageVersionSpecifiedWithOnlyOnePackageVersion(Utility::LocIndView{ package.Id }, Utility::LocIndView{ package.Version }) << std::endl; + foundIssues = true; + } + } + } + } + } + catch (...) + { + AICLI_LOG(Config, Warning, << "Failed to validate WinGetPackage: " << package.Id); + context.Reporter.Warn() << Resource::String::WinGetResourceUnitFailedToValidatePackage(Utility::LocIndView{ package.Id }) << std::endl; + foundIssues = true; + } + } + + return !foundIssues; + } +} \ No newline at end of file diff --git a/src/AppInstallerCLICore/ConfigurationWingetDscModuleUnitValidation.h b/src/AppInstallerCLICore/ConfigurationWingetDscModuleUnitValidation.h new file mode 100644 index 0000000000..3a553ce175 --- /dev/null +++ b/src/AppInstallerCLICore/ConfigurationWingetDscModuleUnitValidation.h @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#pragma once +#include +#include + +namespace winrt::Microsoft::Management::Configuration +{ + struct ConfigurationUnit; +} + +namespace AppInstaller::CLI::Execution +{ + struct Context; +} + +namespace AppInstaller::CLI::Configuration +{ + using namespace std::string_view_literals; + + struct WingetDscModuleUnitValidator + { + bool ValidateConfigurationSetUnit(AppInstaller::CLI::Execution::Context& context, const winrt::Microsoft::Management::Configuration::ConfigurationUnit& unit); + + std::string_view ModuleName() { return "Microsoft.WinGet.DSC"sv; }; + + private: + std::map m_dependenciesSourceAndUnitIdMap; + }; +} diff --git a/src/AppInstallerCLICore/Resources.h b/src/AppInstallerCLICore/Resources.h index 8f96c3c856..55d4e370f0 100644 --- a/src/AppInstallerCLICore/Resources.h +++ b/src/AppInstallerCLICore/Resources.h @@ -583,6 +583,8 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(UpgradeRequireExplicitCount); WINGET_DEFINE_RESOURCE_STRINGID(UpgradeUnknownVersionCount); WINGET_DEFINE_RESOURCE_STRINGID(UpgradeUnknownVersionExplanation); + WINGET_DEFINE_RESOURCE_STRINGID(UriNotWellFormed); + WINGET_DEFINE_RESOURCE_STRINGID(UriSchemeNotSupported); WINGET_DEFINE_RESOURCE_STRINGID(Usage); WINGET_DEFINE_RESOURCE_STRINGID(UserSettings); WINGET_DEFINE_RESOURCE_STRINGID(ValidateCommandLongDescription); @@ -603,6 +605,21 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(WindowsPackageManager); WINGET_DEFINE_RESOURCE_STRINGID(WindowsPackageManagerPreview); WINGET_DEFINE_RESOURCE_STRINGID(WindowsStoreTerms); + WINGET_DEFINE_RESOURCE_STRINGID(WinGetResourceUnitBothPackageVersionAndUseLatest); + WINGET_DEFINE_RESOURCE_STRINGID(WinGetResourceUnitDependencySourceNotConfigured); + WINGET_DEFINE_RESOURCE_STRINGID(WinGetResourceUnitDependencySourceNotDeclaredAsDependency); + WINGET_DEFINE_RESOURCE_STRINGID(WinGetResourceUnitEmptyContent); + WINGET_DEFINE_RESOURCE_STRINGID(WinGetResourceUnitFailedToValidatePackage); + WINGET_DEFINE_RESOURCE_STRINGID(WinGetResourceUnitFailedToValidatePackageMultipleFound); + WINGET_DEFINE_RESOURCE_STRINGID(WinGetResourceUnitFailedToValidatePackageNotFound); + WINGET_DEFINE_RESOURCE_STRINGID(WinGetResourceUnitFailedToValidatePackageSourceOpenFailed); + WINGET_DEFINE_RESOURCE_STRINGID(WinGetResourceUnitFailedToValidatePackageVersionNotFound); + WINGET_DEFINE_RESOURCE_STRINGID(WinGetResourceUnitKnownSourceConfliction); + WINGET_DEFINE_RESOURCE_STRINGID(WinGetResourceUnitMissingRecommendedArg); + WINGET_DEFINE_RESOURCE_STRINGID(WinGetResourceUnitMissingRequiredArg); + WINGET_DEFINE_RESOURCE_STRINGID(WinGetResourceUnitPackageVersionSpecifiedWithOnlyOnePackageVersion); + WINGET_DEFINE_RESOURCE_STRINGID(WinGetResourceUnitThirdPartySourceAssertion); + WINGET_DEFINE_RESOURCE_STRINGID(WinGetResourceUnitThirdPartySourceAssertionForPackage); WINGET_DEFINE_RESOURCE_STRINGID(WordArgumentDescription); }; diff --git a/src/AppInstallerCLICore/Workflows/ConfigurationFlow.cpp b/src/AppInstallerCLICore/Workflows/ConfigurationFlow.cpp index a10d91b7b2..77c1fbd9d1 100644 --- a/src/AppInstallerCLICore/Workflows/ConfigurationFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/ConfigurationFlow.cpp @@ -4,10 +4,13 @@ #include "ConfigurationFlow.h" #include "PromptFlow.h" #include "Public/ConfigurationSetProcessorFactoryRemoting.h" +#include #include +#include #include #include #include "ConfigurationCommon.h" +#include "ConfigurationWingetDscModuleUnitValidation.h" using namespace AppInstaller::CLI::Execution; using namespace winrt::Microsoft::Management::Configuration; @@ -772,10 +775,52 @@ namespace AppInstaller::CLI::Workflow bool m_isFirstProgress = true; }; - std::filesystem::path GetConfigurationFilePath(Execution::Context& context) + std::string GetNormalizedIdentifier(const winrt::hstring& identifier) { - std::filesystem::path argPath = Utility::ConvertToUTF16(context.Args.GetArg(Args::Type::ConfigurationFile)); - return std::filesystem::weakly_canonical(argPath); + return Utility::FoldCase(Utility::NormalizedString{ identifier }); + } + + // Get unit validation order. Make sure dependency units are before units depending on them. + std::vector GetConfigurationSetUnitValidationOrder(winrt::Windows::Foundation::Collections::IVectorView units) + { + // Create id to index map for easier processing. + std::map idToUnitIndex; + for (uint32_t i = 0; i < units.Size(); ++i) + { + auto id = GetNormalizedIdentifier(units.GetAt(i).Identifier()); + if (!id.empty()) + { + idToUnitIndex.emplace(std::move(id), i); + } + } + + // We do not need to worry about duplicate id, missing dependency or loops + // since dependency integrity is already validated in earlier semantic checks. + + std::vector validationOrder; + + std::function addUnitToValidationOrder = + [&](const ConfigurationUnit& unit, uint32_t index) + { + if (std::find(validationOrder.begin(), validationOrder.end(), index) == validationOrder.end()) + { + for (auto const& dependencyId : unit.Dependencies()) + { + auto dependencyIndex = idToUnitIndex.find(GetNormalizedIdentifier(dependencyId))->second; + addUnitToValidationOrder(units.GetAt(dependencyIndex), dependencyIndex); + } + validationOrder.emplace_back(index); + } + }; + + for (uint32_t i = 0; i < units.Size(); ++i) + { + addUnitToValidationOrder(units.GetAt(i), i); + } + + THROW_HR_IF(E_UNEXPECTED, units.Size() != validationOrder.size()); + + return validationOrder; } } @@ -811,10 +856,32 @@ namespace AppInstaller::CLI::Workflow auto progressScope = context.Reporter.BeginAsyncProgress(true); progressScope->Callback().SetProgressMessage(Resource::String::ConfigurationReadingConfigFile()); - std::filesystem::path absolutePath = GetConfigurationFilePath(context); - + std::string argPath{ context.Args.GetArg(Args::Type::ConfigurationFile) }; + std::wstring argPathWide = Utility::ConvertToUTF16(argPath); + bool isRemote = Utility::IsUrlRemote(argPath); + std::filesystem::path absolutePath; Streams::IInputStream inputStream = nullptr; + + if (isRemote) + { + std::ostringstream stringStream; + ProgressCallback emptyCallback; + Utility::DownloadToStream(argPath, stringStream, Utility::DownloadType::ConfigurationFile, emptyCallback); + + auto strContent = stringStream.str(); + std::vector byteContent{ strContent.begin(), strContent.end() }; + + Streams::InMemoryRandomAccessStream memoryStream; + Streams::DataWriter streamWriter{ memoryStream }; + streamWriter.WriteBytes(byteContent); + streamWriter.StoreAsync().get(); + streamWriter.DetachStream(); + memoryStream.Seek(0); + inputStream = memoryStream; + } + else { + absolutePath = std::filesystem::weakly_canonical(std::filesystem::path{ argPathWide }); auto openAction = Streams::FileRandomAccessStream::OpenAsync(absolutePath.wstring(), FileAccessMode::Read); auto cancellationScope = progressScope->Callback().SetCancellationFunction([&]() { openAction.Cancel(); }); inputStream = openAction.get(); @@ -831,7 +898,7 @@ namespace AppInstaller::CLI::Workflow if (FAILED_LOG(static_cast(openResult.ResultCode().value))) { - AICLI_LOG(Config, Error, << "Failed to open configuration set at " << absolutePath.u8string() << " with error 0x" << Logging::SetHRFormat << static_cast(openResult.ResultCode().value)); + AICLI_LOG(Config, Error, << "Failed to open configuration set at " << (isRemote ? argPath : absolutePath.u8string()) << " with error 0x" << Logging::SetHRFormat << static_cast(openResult.ResultCode().value)); switch (openResult.ResultCode()) { @@ -871,10 +938,19 @@ namespace AppInstaller::CLI::Workflow } // Fill out the information about the set based on it coming from a file. - // TODO: Consider how to properly determine a good value for name and origin. - result.Name(absolutePath.filename().wstring()); - result.Origin(absolutePath.parent_path().wstring()); - result.Path(absolutePath.wstring()); + if (isRemote) + { + result.Name(Utility::GetFileNameFromURI(argPath).wstring()); + result.Origin(argPathWide); + // Do not set path. This means ${WinGetConfigRoot} not supported in remote configs. + } + else + { + // TODO: Consider how to properly determine a good value for name and origin. + result.Name(absolutePath.filename().wstring()); + result.Origin(absolutePath.parent_path().wstring()); + result.Path(absolutePath.wstring()); + } context.Get().Set(result); } @@ -1309,6 +1385,36 @@ namespace AppInstaller::CLI::Workflow } } + void ValidateConfigurationSetUnitContents(Execution::Context& context) + { + ConfigurationContext& configContext = context.Get(); + auto units = configContext.Set().Units(); + auto validationOrder = GetConfigurationSetUnitValidationOrder(units.GetView()); + + Configuration::WingetDscModuleUnitValidator wingetUnitValidator; + + bool foundIssues = false; + for (const auto index : validationOrder) + { + const ConfigurationUnit& unit = units.GetAt(index); + auto moduleName = Utility::ConvertToUTF8(unit.Details().ModuleName()); + if (Utility::CaseInsensitiveEquals(wingetUnitValidator.ModuleName(), moduleName)) + { + bool result = wingetUnitValidator.ValidateConfigurationSetUnit(context, unit); + if (!result) + { + foundIssues = true; + } + } + } + + if (foundIssues) + { + // Indicate that it was not a total success + AICLI_TERMINATE_CONTEXT(S_FALSE); + } + } + void ValidateAllGoodMessage(Execution::Context& context) { context.Reporter.Info() << Resource::String::ConfigurationValidationFoundNoIssues << std::endl; diff --git a/src/AppInstallerCLICore/Workflows/ConfigurationFlow.h b/src/AppInstallerCLICore/Workflows/ConfigurationFlow.h index a5dcb44e76..d1b50fc104 100644 --- a/src/AppInstallerCLICore/Workflows/ConfigurationFlow.h +++ b/src/AppInstallerCLICore/Workflows/ConfigurationFlow.h @@ -73,6 +73,12 @@ namespace AppInstaller::CLI::Workflow // Outputs: None void ValidateConfigurationSetUnitProcessors(Execution::Context& context); + // Validates that specific unit contents referenced by the set are valid/available/etc. + // Required Args: None + // Inputs: ConfigurationProcessor, ConfigurationSet + // Outputs: None + void ValidateConfigurationSetUnitContents(Execution::Context& context); + // Outputs the final message stating that no issues were found. // Required Args: None // Inputs: None diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index a3ae991290..f3f2082992 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -16,6 +16,7 @@ using namespace AppInstaller::Utility::literals; using namespace AppInstaller::Pinning; using namespace AppInstaller::Repository; using namespace AppInstaller::Settings; +using namespace winrt::Windows::Foundation; namespace AppInstaller::CLI::Workflow { @@ -1093,6 +1094,49 @@ namespace AppInstaller::CLI::Workflow } } + void VerifyFileOrUri::operator()(Execution::Context& context) const + { + auto path = context.Args.GetArg(m_arg); + + // try uri first + Uri pathAsUri = nullptr; + try + { + pathAsUri = Uri{ Utility::ConvertToUTF16(path) }; + } + catch (...) {} + + if (pathAsUri) + { + if (pathAsUri.Suspicious()) + { + context.Reporter.Error() << Resource::String::UriNotWellFormed(Utility::LocIndView{ path }) << std::endl; + AICLI_TERMINATE_CONTEXT(HRESULT_FROM_WIN32(ERROR_NOT_SUPPORTED)); + } + // SchemeName() always returns lower case + else if (L"file" == pathAsUri.SchemeName() && !Utility::CaseInsensitiveStartsWith(path, "file:")) + { + // Uri constructor is smart enough to parse an absolute local file path to file uri. + // In this case, we should continue with VerifyFile. + context << VerifyFile(m_arg); + } + else if (std::find(m_supportedSchemes.begin(), m_supportedSchemes.end(), pathAsUri.SchemeName()) != m_supportedSchemes.end()) + { + // Scheme supported. + return; + } + else + { + context.Reporter.Error() << Resource::String::UriSchemeNotSupported(Utility::LocIndView{ path }) << std::endl; + AICLI_TERMINATE_CONTEXT(HRESULT_FROM_WIN32(ERROR_NOT_SUPPORTED)); + } + } + else + { + context << VerifyFile(m_arg); + } + } + void GetManifestFromArg(Execution::Context& context) { Logging::Telemetry().LogIsManifestLocal(true); diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.h b/src/AppInstallerCLICore/Workflows/WorkflowBase.h index 744c24e44d..51162d3084 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.h +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.h @@ -320,6 +320,22 @@ namespace AppInstaller::CLI::Workflow Execution::Args::Type m_arg; }; + // Ensures the local file exists and is not a directory. Or it's a Uri. Default only https is supported at the moment. + // Required Args: the one given + // Inputs: None + // Outputs: None + struct VerifyFileOrUri : public WorkflowTask + { + VerifyFileOrUri(Execution::Args::Type arg, std::vector supportedSchemes = { L"https" }) : + WorkflowTask("VerifyFileOrUri"), m_arg(arg), m_supportedSchemes(std::move(supportedSchemes)) {} + + void operator()(Execution::Context& context) const override; + + private: + Execution::Args::Type m_arg; + std::vector m_supportedSchemes; + }; + // Opens the manifest file provided on the command line. // Required Args: Manifest // Inputs: None diff --git a/src/AppInstallerCLIE2ETests/AppInstallerCLIE2ETests.csproj b/src/AppInstallerCLIE2ETests/AppInstallerCLIE2ETests.csproj index 6040582860..7a724c4a23 100644 --- a/src/AppInstallerCLIE2ETests/AppInstallerCLIE2ETests.csproj +++ b/src/AppInstallerCLIE2ETests/AppInstallerCLIE2ETests.csproj @@ -50,25 +50,8 @@ - - - - - - - - - - - - - - - - - PreserveNewest diff --git a/src/AppInstallerCLIE2ETests/ConfigureCommand.cs b/src/AppInstallerCLIE2ETests/ConfigureCommand.cs index 1626c253ea..8e1b7fdb0d 100644 --- a/src/AppInstallerCLIE2ETests/ConfigureCommand.cs +++ b/src/AppInstallerCLIE2ETests/ConfigureCommand.cs @@ -170,6 +170,18 @@ public void ResourceCaseInsensitive() Assert.AreEqual("Contents!", System.IO.File.ReadAllText(targetFilePath)); } + /// + /// Simple test to configure from an https configuration file. + /// + [Test] + public void ConfigureFromHttpsConfigurationFile() + { + string args = $"{Constants.TestSourceUrl}/TestData/Configuration/Configure_TestRepo_Location.yml"; + + var result = TestCommon.RunAICLICommand(CommandAndAgreementsAndVerbose, args); + Assert.AreEqual(0, result.ExitCode); + } + private void DeleteTxtFiles() { // Delete all .txt files in the test directory; they are placed there by the tests diff --git a/src/AppInstallerCLIE2ETests/ConfigureShowCommand.cs b/src/AppInstallerCLIE2ETests/ConfigureShowCommand.cs index 828e36ef39..115fa83d24 100644 --- a/src/AppInstallerCLIE2ETests/ConfigureShowCommand.cs +++ b/src/AppInstallerCLIE2ETests/ConfigureShowCommand.cs @@ -109,5 +109,16 @@ public void ShowDetails_Schema0_3_Parameters() Assert.AreEqual(0, result.ExitCode); Assert.True(result.StdOut.Contains("Failed to get detailed information about the configuration.")); } + + /// + /// Simple test to show details from a https configuration file. + /// + [Test] + public void ShowDetailsFromHttpsConfigurationFile() + { + var result = TestCommon.RunAICLICommand("configure show", $"{Constants.TestSourceUrl}/TestData/Configuration/ShowDetails_TestRepo.yml --verbose"); + Assert.AreEqual(0, result.ExitCode); + Assert.True(result.StdOut.Contains(Constants.TestRepoName)); + } } } diff --git a/src/AppInstallerCLIE2ETests/ConfigureTestCommand.cs b/src/AppInstallerCLIE2ETests/ConfigureTestCommand.cs index 4bfb87d5b8..e186c71635 100644 --- a/src/AppInstallerCLIE2ETests/ConfigureTestCommand.cs +++ b/src/AppInstallerCLIE2ETests/ConfigureTestCommand.cs @@ -79,6 +79,17 @@ public void ConfigureTest_TestFailure() Assert.True(result.StdOut.Contains("System is not in the described configuration state.")); } + /// + /// Test from https configuration file. + /// + [Test] + public void ConfigureTest_HttpsConfigurationFile() + { + var result = TestCommon.RunAICLICommand(CommandAndAgreements, $"{Constants.TestSourceUrl}/TestData/Configuration/Configure_TestRepo_Location.yml"); + Assert.AreEqual(Constants.ErrorCode.S_OK, result.ExitCode); + Assert.True(result.StdOut.Contains("System is in the described configuration state.")); + } + private void DeleteTxtFiles() { // Delete all .txt files in the test directory; they are placed there by the tests diff --git a/src/AppInstallerCLIE2ETests/ConfigureValidateCommand.cs b/src/AppInstallerCLIE2ETests/ConfigureValidateCommand.cs index 1ee1a4212a..9e9a03a95a 100644 --- a/src/AppInstallerCLIE2ETests/ConfigureValidateCommand.cs +++ b/src/AppInstallerCLIE2ETests/ConfigureValidateCommand.cs @@ -16,6 +16,24 @@ public class ConfigureValidateCommand { private const string Command = "configure validate"; + /// + /// Set up. + /// + [OneTimeSetUp] + public void BaseSetup() + { + TestCommon.SetupTestSource(false); + } + + /// + /// Tear down. + /// + [OneTimeTearDown] + public void BaseTeardown() + { + TestCommon.TearDownTestSource(); + } + /// /// The configuration file is empty. /// @@ -191,5 +209,93 @@ public void NoIssuesDetected() Assert.AreEqual(Constants.ErrorCode.S_OK, result.ExitCode); Assert.True(result.StdOut.Contains("Validation found no issues.")); } + + /// + /// No issues detected (yet) from https configuration file. + /// + [Test] + public void NoIssuesDetected_HttpsConfigurationFile() + { + var result = TestCommon.RunAICLICommand(Command, $"{Constants.TestSourceUrl}/TestData/Configuration/PSGallery_NoSettings.yml", timeOut: 120000); + Assert.AreEqual(Constants.ErrorCode.S_OK, result.ExitCode); + Assert.True(result.StdOut.Contains("Validation found no issues.")); + } + + /// + /// No issues detected from WinGet resource units. + /// + [Test] + public void NoIssuesDetected_WinGetDscResource() + { + var result = TestCommon.RunAICLICommand(Command, TestCommon.GetTestDataFile("Configuration\\WinGetDscResourceValidate_Good.yml"), timeOut: 120000); + Assert.AreEqual(Constants.ErrorCode.S_OK, result.ExitCode); + Assert.True(result.StdOut.Contains("Validation found no issues.")); + } + + /// + /// No issues detected from WinGet resource units. + /// + [Test] + public void ValidateWinGetDscResource_DependencySourceMissing() + { + var result = TestCommon.RunAICLICommand(Command, TestCommon.GetTestDataFile("Configuration\\WinGetDscResourceValidate_DependencySourceMissing.yml"), timeOut: 120000); + Assert.AreEqual(Constants.ErrorCode.S_FALSE, result.ExitCode); + Assert.True(result.StdOut.Contains("WinGetPackage configuration unit package depends on a third-party source not previously configured. Package Id: AppInstallerTest.TestExeInstaller; Source: TestSource")); + } + + /// + /// No issues detected from WinGet resource units. + /// + [Test] + public void ValidateWinGetDscResource_PackageNotFound() + { + var result = TestCommon.RunAICLICommand(Command, TestCommon.GetTestDataFile("Configuration\\WinGetDscResourceValidate_PackageNotFound.yml"), timeOut: 120000); + Assert.AreEqual(Constants.ErrorCode.S_FALSE, result.ExitCode); + Assert.True(result.StdOut.Contains("WinGetPackage configuration unit package cannot be validated. Package not found. Package Id: AppInstallerTest.DoesNotExist")); + } + + /// + /// No issues detected from WinGet resource units. + /// + [Test] + public void ValidateWinGetDscResource_PackageVersionNotFound() + { + var result = TestCommon.RunAICLICommand(Command, TestCommon.GetTestDataFile("Configuration\\WinGetDscResourceValidate_PackageVersionNotFound.yml"), timeOut: 120000); + Assert.AreEqual(Constants.ErrorCode.S_FALSE, result.ExitCode); + Assert.True(result.StdOut.Contains("WinGetPackage configuration unit package cannot be validated. Package version not found. Package Id: AppInstallerTest.TestExeInstaller; Version 101.0.101.0")); + } + + /// + /// No issues detected from WinGet resource units. + /// + [Test] + public void ValidateWinGetDscResource_SourceOpenFailed() + { + var result = TestCommon.RunAICLICommand(Command, TestCommon.GetTestDataFile("Configuration\\WinGetDscResourceValidate_SourceOpenFailed.yml"), timeOut: 120000); + Assert.AreEqual(Constants.ErrorCode.S_FALSE, result.ExitCode); + Assert.True(result.StdOut.Contains("WinGetPackage configuration unit package cannot be validated. Source open failed. Package Id: AppInstallerTest.TestExeInstaller; Source: TestSourceV2")); + } + + /// + /// No issues detected from WinGet resource units. + /// + [Test] + public void ValidateWinGetDscResource_VersionSpecifiedWithOnlyOneVersionAvailable() + { + var result = TestCommon.RunAICLICommand(Command, TestCommon.GetTestDataFile("Configuration\\WinGetDscResourceValidate_VersionSpecifiedWithOnlyOneVersionAvailable.yml"), timeOut: 120000); + Assert.AreEqual(Constants.ErrorCode.S_FALSE, result.ExitCode); + Assert.True(result.StdOut.Contains("WinGetPackage configuration unit package specified with a specific version while only one package version is available. Package Id: AppInstallerTest.TestValidManifest; Version: 1.0.0.0")); + } + + /// + /// No issues detected from WinGet resource units. + /// + [Test] + public void ValidateWinGetDscResource_VersionSpecifiedWithUseLatest() + { + var result = TestCommon.RunAICLICommand(Command, TestCommon.GetTestDataFile("Configuration\\WinGetDscResourceValidate_VersionSpecifiedWithUseLatest.yml"), timeOut: 120000); + Assert.AreEqual(Constants.ErrorCode.S_FALSE, result.ExitCode); + Assert.True(result.StdOut.Contains("WinGetPackage declares both UseLatest and Version. Package Id: AppInstallerTest.TestExeInstaller")); + } } } diff --git a/src/AppInstallerCLIE2ETests/TestData/Configuration/WinGetDscResourceValidate_DependencySourceMissing.yml b/src/AppInstallerCLIE2ETests/TestData/Configuration/WinGetDscResourceValidate_DependencySourceMissing.yml new file mode 100644 index 0000000000..e21b16870c --- /dev/null +++ b/src/AppInstallerCLIE2ETests/TestData/Configuration/WinGetDscResourceValidate_DependencySourceMissing.yml @@ -0,0 +1,13 @@ +properties: + configurationVersion: 0.2.0 + resources: + - resource: Microsoft.WinGet.DSC/WinGetPackage + id: testPackage + directives: + description: Install Test Package + allowPrerelease: true + settings: + id: AppInstallerTest.TestExeInstaller + source: TestSource + version: "1.0.1.0" + \ No newline at end of file diff --git a/src/AppInstallerCLIE2ETests/TestData/Configuration/WinGetDscResourceValidate_Good.yml b/src/AppInstallerCLIE2ETests/TestData/Configuration/WinGetDscResourceValidate_Good.yml new file mode 100644 index 0000000000..3cfea23b14 --- /dev/null +++ b/src/AppInstallerCLIE2ETests/TestData/Configuration/WinGetDscResourceValidate_Good.yml @@ -0,0 +1,24 @@ +properties: + configurationVersion: 0.2.0 + resources: + - resource: Microsoft.WinGet.DSC/WinGetSources + id: configureSource + directives: + description: Add test source + allowPrerelease: true + settings: + Sources: + - Name: TestSource + Arg: "https://localhost:5001/TestKit" + - resource: Microsoft.WinGet.DSC/WinGetPackage + id: testPackage + dependsOn: + - configureSource + directives: + description: Install Test Package + allowPrerelease: true + settings: + id: AppInstallerTest.TestExeInstaller + source: TestSource + version: "1.0.1.0" + \ No newline at end of file diff --git a/src/AppInstallerCLIE2ETests/TestData/Configuration/WinGetDscResourceValidate_PackageNotFound.yml b/src/AppInstallerCLIE2ETests/TestData/Configuration/WinGetDscResourceValidate_PackageNotFound.yml new file mode 100644 index 0000000000..6b7633ba50 --- /dev/null +++ b/src/AppInstallerCLIE2ETests/TestData/Configuration/WinGetDscResourceValidate_PackageNotFound.yml @@ -0,0 +1,24 @@ +properties: + configurationVersion: 0.2.0 + resources: + - resource: Microsoft.WinGet.DSC/WinGetSources + id: configureSource + directives: + description: Add test source + allowPrerelease: true + settings: + Sources: + - Name: TestSource + Arg: "https://localhost:5001/TestKit" + - resource: Microsoft.WinGet.DSC/WinGetPackage + id: testPackage + dependsOn: + - configureSource + directives: + description: Install Test Package + allowPrerelease: true + settings: + id: AppInstallerTest.DoesNotExist + source: TestSource + version: "1.0.1.0" + \ No newline at end of file diff --git a/src/AppInstallerCLIE2ETests/TestData/Configuration/WinGetDscResourceValidate_PackageVersionNotFound.yml b/src/AppInstallerCLIE2ETests/TestData/Configuration/WinGetDscResourceValidate_PackageVersionNotFound.yml new file mode 100644 index 0000000000..1dacdfb090 --- /dev/null +++ b/src/AppInstallerCLIE2ETests/TestData/Configuration/WinGetDscResourceValidate_PackageVersionNotFound.yml @@ -0,0 +1,24 @@ +properties: + configurationVersion: 0.2.0 + resources: + - resource: Microsoft.WinGet.DSC/WinGetSources + id: configureSource + directives: + description: Add test source + allowPrerelease: true + settings: + Sources: + - Name: TestSource + Arg: "https://localhost:5001/TestKit" + - resource: Microsoft.WinGet.DSC/WinGetPackage + id: testPackage + dependsOn: + - configureSource + directives: + description: Install Test Package + allowPrerelease: true + settings: + id: AppInstallerTest.TestExeInstaller + source: TestSource + version: "101.0.101.0" + \ No newline at end of file diff --git a/src/AppInstallerCLIE2ETests/TestData/Configuration/WinGetDscResourceValidate_SourceOpenFailed.yml b/src/AppInstallerCLIE2ETests/TestData/Configuration/WinGetDscResourceValidate_SourceOpenFailed.yml new file mode 100644 index 0000000000..d94a17b548 --- /dev/null +++ b/src/AppInstallerCLIE2ETests/TestData/Configuration/WinGetDscResourceValidate_SourceOpenFailed.yml @@ -0,0 +1,24 @@ +properties: + configurationVersion: 0.2.0 + resources: + - resource: Microsoft.WinGet.DSC/WinGetSources + id: configureSource + directives: + description: Add test source + allowPrerelease: true + settings: + Sources: + - Name: TestSourceV2 + Arg: "https://localhost:5001/TestKit" + - resource: Microsoft.WinGet.DSC/WinGetPackage + id: testPackage + dependsOn: + - configureSource + directives: + description: Install Test Package + allowPrerelease: true + settings: + id: AppInstallerTest.TestExeInstaller + source: TestSourceV2 + version: "1.0.1.0" + \ No newline at end of file diff --git a/src/AppInstallerCLIE2ETests/TestData/Configuration/WinGetDscResourceValidate_VersionSpecifiedWithOnlyOneVersionAvailable.yml b/src/AppInstallerCLIE2ETests/TestData/Configuration/WinGetDscResourceValidate_VersionSpecifiedWithOnlyOneVersionAvailable.yml new file mode 100644 index 0000000000..3fc0f6dac2 --- /dev/null +++ b/src/AppInstallerCLIE2ETests/TestData/Configuration/WinGetDscResourceValidate_VersionSpecifiedWithOnlyOneVersionAvailable.yml @@ -0,0 +1,24 @@ +properties: + configurationVersion: 0.2.0 + resources: + - resource: Microsoft.WinGet.DSC/WinGetSources + id: configureSource + directives: + description: Add test source + allowPrerelease: true + settings: + Sources: + - Name: TestSource + Arg: "https://localhost:5001/TestKit" + - resource: Microsoft.WinGet.DSC/WinGetPackage + id: testPackage + dependsOn: + - configureSource + directives: + description: Install Test Package + allowPrerelease: true + settings: + id: AppInstallerTest.TestValidManifest + source: TestSource + version: "1.0.0.0" + \ No newline at end of file diff --git a/src/AppInstallerCLIE2ETests/TestData/Configuration/WinGetDscResourceValidate_VersionSpecifiedWithUseLatest.yml b/src/AppInstallerCLIE2ETests/TestData/Configuration/WinGetDscResourceValidate_VersionSpecifiedWithUseLatest.yml new file mode 100644 index 0000000000..3b1bd82dcc --- /dev/null +++ b/src/AppInstallerCLIE2ETests/TestData/Configuration/WinGetDscResourceValidate_VersionSpecifiedWithUseLatest.yml @@ -0,0 +1,25 @@ +properties: + configurationVersion: 0.2.0 + resources: + - resource: Microsoft.WinGet.DSC/WinGetSources + id: configureSource + directives: + description: Add test source + allowPrerelease: true + settings: + Sources: + - Name: TestSource + Arg: "https://localhost:5001/TestKit" + - resource: Microsoft.WinGet.DSC/WinGetPackage + id: testPackage + dependsOn: + - configureSource + directives: + description: Install Test Package + allowPrerelease: true + settings: + id: AppInstallerTest.TestExeInstaller + source: TestSource + version: "1.0.1.0" + useLatest: true + \ No newline at end of file diff --git a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw index 5004c355b4..e6d2457040 100644 --- a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw +++ b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw @@ -2074,7 +2074,7 @@ Please specify one of them using the --source option to proceed. Specifies the location on the local computer to store modules. Default %LOCALAPPDATA%\Microsoft\WinGet\Configuration\Modules - {Locked="allusers","currentuser","default","%LOCALAPPDATA%\Microsoft\WinGet\Configuration\Modules"} + {Locked="%LOCALAPPDATA%\Microsoft\WinGet\Configuration\Modules"} `--module-path` value must be `currentuser`, `allusers`, `default` or an absolute path. @@ -2638,4 +2638,72 @@ Please specify one of them using the --source option to proceed. Ignore the limit on resuming a saved state + + Uri scheme not supported: {0} + {Locked="{0}"} Error message displayed when the provided uri is not supported. {0} is a placeholder replaced by the provided uri. + + + Uri not well formed: {0} + {Locked="{0}"} Error message displayed when the provided uri is not well formed. {0} is a placeholder replaced by the provided uri. + + + Failed to parse {0} configuration unit settings content or settings content is empty. + {Locked="{0}"} {0} is a placeholder replaced by the input winget configure resource unit type. + + + {0} configuration unit is missing required argument: {1} + {Locked="{0},{1}"} {0} is a placeholder for the input winget configure resource unit type. {1} is placeholder for the missing arg. + + + {0} configuration unit is missing recommended argument: {1} + {Locked="{0},{1}"} {0} is a placeholder for the input winget configure resource unit type. {1} is placeholder for the missing arg. + + + WinGetSource configuration unit conflicts with a known source: {0} + {Locked="WinGetSource,{0}"} {0} is a placeholder for the input winget source in the configuration unit settings. + + + WinGetSource configuration unit asserts on a third-party source: {0} + {Locked="WinGetSource,{0}"} {0} is a placeholder for the input winget source in the configuration unit settings. + + + WinGetPackage declares both UseLatest and Version. Package Id: {0} + {Locked="WinGetPackage,UseLatest,Version,{0}"} + + + WinGetPackage configuration unit asserts on a package from third-party source. Package Id: {0}; Source: {1} + {Locked="WinGetPackage,{0},{1}"} + + + WinGetPackage configuration unit package depends on a third-party source not previously configured. Package Id: {0}; Source: {1} + {Locked="WinGetPackage,{0},{1}"} + + + WinGetPackage configuration unit package depends on a 3rd party source. It is recommended to declare the dependency in uni dependsOn section. Package Id: {0}; Source: {1} + {Locked="WinGetPackage,dependsOn,{0},{1}"} + + + WinGetPackage configuration unit package cannot be validated. Source open failed. Package Id: {0}; Source: {1} + {Locked="WinGetPackage,{0},{1}"} + + + WinGetPackage configuration unit package cannot be validated. Package not found. Package Id: {0} + {Locked="WinGetPackage,{0}"} + + + WinGetPackage configuration unit package cannot be validated. More than one package found. Package Id: {0} + {Locked="WinGetPackage,{0}"} + + + WinGetPackage configuration unit package cannot be validated. Package version not found. Package Id: {0}; Version {1} + {Locked="WinGetPackage,{0},{1}"} + + + WinGetPackage configuration unit package specified with a specific version while only one package version is available. Package Id: {0}; Version: {1} + {Locked="WinGetPackage,{0},{1}"} + + + WinGetPackage configuration unit package cannot be validated. Package Id: {0} + {Locked="WinGetPackage,{0}"} + \ No newline at end of file diff --git a/src/AppInstallerCommonCore/Public/AppInstallerDownloader.h b/src/AppInstallerCommonCore/Public/AppInstallerDownloader.h index aa202cbb7c..c99e10767c 100644 --- a/src/AppInstallerCommonCore/Public/AppInstallerDownloader.h +++ b/src/AppInstallerCommonCore/Public/AppInstallerDownloader.h @@ -31,6 +31,7 @@ namespace AppInstaller::Utility WinGetUtil, Installer, InstallerMetadataCollectionInput, + ConfigurationFile, }; // Extra metadata about a download for use by certain downloaders (Delivery Optimization for instance). diff --git a/src/LocalhostWebServer/Program.cs b/src/LocalhostWebServer/Program.cs index 7456cbd28e..df84c6416c 100644 --- a/src/LocalhostWebServer/Program.cs +++ b/src/LocalhostWebServer/Program.cs @@ -14,6 +14,7 @@ namespace LocalhostWebServer using System.Text.Json; using WinGetSourceCreator.Model; using Microsoft.WinGetSourceCreator; + using System.Runtime.InteropServices; public class Program { @@ -33,7 +34,8 @@ static void Main(string[] args) Startup.Port = config.GetValue("Port", 5001); Startup.OutCertFile = config.GetValue("OutCertFile"); Startup.LocalSourceJson = config.GetValue("LocalSourceJson"); - + Startup.TestDataPath = config.GetValue("TestDataPath"); + if (string.IsNullOrEmpty(Startup.StaticFileRoot) || string.IsNullOrEmpty(Startup.CertPath)) { @@ -112,6 +114,19 @@ static void Main(string[] args) WinGetLocalSource.CreateFromLocalSourceFile(Startup.LocalSourceJson); } + if (!string.IsNullOrEmpty(Startup.TestDataPath)) + { + if (!Directory.Exists(Startup.TestDataPath)) + { + throw new DirectoryNotFoundException(Startup.TestDataPath); + } + + var testDataDirectory = Path.Combine(Startup.StaticFileRoot, "TestData"); + Directory.CreateDirectory(testDataDirectory); + + CopyDirectoryRecursive(Startup.TestDataPath, testDataDirectory); + } + CreateHostBuilder(args).Build().Run(); } @@ -120,7 +135,7 @@ public static IHostBuilder CreateHostBuilder(string[] args) => .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseKestrel(opt => - { + { opt.ListenAnyIP(Startup.Port, listOpt => { listOpt.UseHttps(ServerCertificate); @@ -131,5 +146,27 @@ public static IHostBuilder CreateHostBuilder(string[] args) => }); public static X509Certificate2 ServerCertificate { get; private set; } + + private static void CopyDirectoryRecursive(string sourceDir, string destDir) + { + if (!Directory.Exists(destDir)) + { + Directory.CreateDirectory(destDir); + } + + string[] files = Directory.GetFiles(sourceDir); + foreach (string file in files) + { + string dest = Path.Combine(destDir, Path.GetFileName(file)); + File.Copy(file, dest); + } + + string[] directories = Directory.GetDirectories(sourceDir); + foreach (string dir in directories) + { + string dest = Path.Combine(destDir, Path.GetFileName(dir)); + CopyDirectoryRecursive(dir, dest); + } + } } } \ No newline at end of file diff --git a/src/LocalhostWebServer/Run-LocalhostWebServer.ps1 b/src/LocalhostWebServer/Run-LocalhostWebServer.ps1 index f17e467545..a8142e6f21 100644 --- a/src/LocalhostWebServer/Run-LocalhostWebServer.ps1 +++ b/src/LocalhostWebServer/Run-LocalhostWebServer.ps1 @@ -37,7 +37,10 @@ param( [string]$LocalSourceJson, [Parameter()] - [string]$SourceCert + [string]$SourceCert, + + [Parameter()] + [string]$TestDataPath ) if (-not [System.String]::IsNullOrEmpty($sourceCert)) @@ -48,6 +51,6 @@ if (-not [System.String]::IsNullOrEmpty($sourceCert)) Push-Location $BuildRoot -Start-Process -FilePath "LocalhostWebServer.exe" -ArgumentList "StaticFileRoot=$StaticFileRoot CertPath=$CertPath CertPassword=$CertPassword OutCertFile=$OutCertFile LocalSourceJson=$LocalSourceJson" +Start-Process -FilePath "LocalhostWebServer.exe" -ArgumentList "StaticFileRoot=$StaticFileRoot CertPath=$CertPath CertPassword=$CertPassword OutCertFile=$OutCertFile LocalSourceJson=$LocalSourceJson TestDataPath=$TestDataPath" Pop-Location \ No newline at end of file diff --git a/src/LocalhostWebServer/Startup.cs b/src/LocalhostWebServer/Startup.cs index db5706f136..7087e47182 100644 --- a/src/LocalhostWebServer/Startup.cs +++ b/src/LocalhostWebServer/Startup.cs @@ -27,6 +27,8 @@ public class Startup public static string LocalSourceJson { get; set; } + public static string TestDataPath { get; set; } + public Startup(IConfiguration configuration) { Configuration = configuration; @@ -50,6 +52,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) //Add .yaml and .msix mappings var provider = new FileExtensionContentTypeProvider(); + provider.Mappings[".yml"] = "application/x-yaml"; provider.Mappings[".yaml"] = "application/x-yaml"; provider.Mappings[".msix"] = "application/msix"; provider.Mappings[".exe"] = "application/x-msdownload"; diff --git a/src/PowerShell/Microsoft.WinGet.Client.Engine/Common/WinGetIntegrity.cs b/src/PowerShell/Microsoft.WinGet.Client.Engine/Common/WinGetIntegrity.cs index 1d4d0943ec..cd8932e8de 100644 --- a/src/PowerShell/Microsoft.WinGet.Client.Engine/Common/WinGetIntegrity.cs +++ b/src/PowerShell/Microsoft.WinGet.Client.Engine/Common/WinGetIntegrity.cs @@ -135,7 +135,7 @@ private static IntegrityCategory GetReason(PowerShellCmdlet pwshCmdlet) } } - // Not under %LOCALAPPDATA%\\Microsoft\\WindowsApps\PFM\ + // Not under %LOCALAPPDATA%\\Microsoft\\WindowsApps\PFN\ // Check OS version if (!IsSupportedOSVersion())