commit 04fb4cc1e74c530f4549d1c966b5673978e2c40e parent 04403d434c44564e64aacb518d2d7d4fab80851f Author: Ruben Guerrero <rubengu@microsoft.com> Date: Tue, 1 Aug 2023 10:51:21 -0700 Simplify creating local index (#3445) The goal of this PR is to simplify how the test source generated and have a convenient way recreate it locally or to be consume that other agents (aka PowerShell E2E). There's a new `WinGetSourceCreator` project that incorporates what the E2E setup and IndexCreationTool did. `WinGetLocalSource.CreateLocalSource` is a convenience method in charge of processing that object by: 1. Preparing all local installers by copying them to the working directory and signing them if needed. 2. Preparing all dynamic installers. A dynamic installer is on that is generated while creating the source. Currently it only supports zip installers. 3. Copying all manifest files into the working directory and replace any hash token from previously processed installers. Includes signature hash for msix. 4. Create the index.db file using WinGetUtil 5. Create source.msix and sign the package `LocalSource` is an object that declares all the artifacts needed to produce a local winget source and is the input of `WinGetLocalSource.CreateLocalSource`. It can also be serialized and consumed as a json file by `WinGetLocalSource.CreateFromLocalSourceFile`. The input json can also reference environment variables (in its Windows version for batch) and they will be expanded by `WinGetLocalSource.CreateFromLocalSourceFile`. The E2E local source json is the following: ``` { "AppxManifest": "%BUILD_SOURCESDIRECTORY%/src/AppInstallerCLIE2ETests/TestData/Package/AppxManifest.xml", "WorkingDirectory": "%AGENT_TEMPDIRECTORY%/TestLocalIndex", "LocalManifests": [ "%BUILD_SOURCESDIRECTORY%/src/AppInstallerCLIE2ETests/TestData/Manifests" ], "LocalInstallers": [ { "Type": "exe", "Name": "AppInstallerTestExeInstaller/AppInstallerTestExeInstaller.exe", "Input": "%BUILDOUTDIR%/AppInstallerTestExeInstaller/AppInstallerTestExeInstaller.exe", "HashToken": "<EXEHASH>" }, { "Type": "msi", "Name": "AppInstallerTestMsiInstaller/AppInstallerTestMsiInstaller.msi", "Input": "%BUILD_SOURCESDIRECTORY%/src/AppInstallerCLIE2ETests/TestData/AppInstallerTestMsiInstaller.msi", "HashToken": "<MSIHASH>" }, { "Type": "msix", "Name": "AppInstallerTestMsixInstaller/AppInstallerTestMsixInstaller.msix", "Input": "%BUILD_ARTIFACTSTAGINGDIRECTORY%/AppInstallerTestMsixInstaller.msix", "HashToken": "<MSIXHASH>", "SignatureToken": "<SIGNATUREHASH>" } ], "DynamicInstallers": [ { "Type": "zip", "Name": "AppInstallerTestZipInstaller/AppInstallerTestZipInstaller.zip", "Input": [ "%AGENT_TEMPDIRECTORY%/TestLocalIndex/AppInstallerTestExeInstaller/AppInstallerTestExeInstaller.exe", "%AGENT_TEMPDIRECTORY%/TestLocalIndex/AppInstallerTestMsiInstaller/AppInstallerTestMsiInstaller.msi", "%AGENT_TEMPDIRECTORY%/TestLocalIndex/AppInstallerTestMsixInstaller/AppInstallerTestMsixInstaller.msix" ], "HashToken": "<ZIPHASH>" } ], "Signature": { "CertFile": "%APPINSTALLERTEST_SECUREFILEPATH%" } } ``` `src/AppInstallerCLIE2ETests/README.md` contains more information about how to run the tests The source is generated but in order to be usable by winget it needs to be hosted in the localserver web host. Before this change, each run of the E2E will generate the same test source. Now it will be produced at the Launch LocalhostWebServer time and the E2E assumes everything is set up correctly. The E2E can still generate the source given the necessary arguments if needed. The code that generates the source is the same. I also modified how the test runtime parameters are consumed in the E2E project. This should make it easier to anyone to run them locally without that much pain for test that don't require the test source or the localhost web server. I also moved how the com traces can be enabled via the System.Debug variable so it can work from master too. Verified by having the same number of test passed/skip in each phase. ``` Run E2E Tests Packaged Total tests: 222 Passed: 215 Skipped: 7 Run Microsoft.Management.Deployment E2E Tests (In-process) Total tests: 57 Passed: 52 Skipped: 5 Run Microsoft.Management.Deployment E2E Tests (Out-of-process) Total tests: 57 Passed: 53 Skipped: 4 ``` ###### Microsoft Reviewers: codeflow:open?pullrequest=https://github.com/microsoft/winget-cli/pull/3445&drop=dogfoodAlpha Diffstat:
77 files changed, 3294 insertions(+), 2371 deletions(-)
diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt @@ -33,6 +33,7 @@ ASwitch ASYNCRTIMP Atest ATL +attr AType AUrl Authenticode @@ -56,6 +57,7 @@ boundparms bpp Browsable BSODs +BUILDOUTDIR buildtransitive BUILTINS cancelledbyuser @@ -121,6 +123,7 @@ ENDSESSION EQU errmsg ERRORONEXIT +ESource ESRB etest etl @@ -224,6 +227,7 @@ Linq liv liwpx localizationpriority +localsource LOWORD LPARAM LPBYTE @@ -394,6 +398,7 @@ rzkzqaqjwj SARL schematab sddl +SECUREFILEPATH seof servercert servercertificate @@ -410,6 +415,7 @@ SLAPI SMTO sortof sourceforge +SOURCESDIRECTORY spamming SPAPI Srinivasan @@ -434,6 +440,7 @@ systemnotsupported Tagit TCpp tcs +TEMPDIRECTORY Templating temppath testdata @@ -476,9 +483,9 @@ userfilesetting userprofile UWP VALUENAMECASE +vclib VERSI VERSIE -vclib vns vsconfig vstest diff --git a/azure-pipelines.yml b/azure-pipelines.yml @@ -178,18 +178,6 @@ jobs: testResultsFiles: '$(artifactsDir)\TEST-*.xml' failTaskOnFailedTests: true - - task: DownloadSecureFile@1 - name: AppInstallerTest - displayName: 'Download Source Package Certificate' - inputs: - secureFile: 'AppInstallerTest.pfx' - - - task: DownloadSecureFile@1 - name: HTTPSDevCert - displayName: 'Download Kestrel Certificate' - inputs: - secureFile: 'HTTPSDevCertV2.pfx' - - task: MSBuild@1 displayName: Build MSIX Test Installer File inputs: @@ -202,18 +190,6 @@ jobs: /p:AppxPackageSigningEnabled=false' - task: PowerShell@2 - displayName: Install Root Certificate - inputs: - filePath: 'src\LocalhostWebServer\InstallDevCert.ps1' - arguments: '-pfxpath $(HTTPSDevCert.secureFilePath) -password microsoft' - - - task: PowerShell@2 - displayName: Launch LocalhostWebServer - inputs: - filePath: 'src\LocalhostWebServer\Run-LocalhostWebServer.ps1' - arguments: '-BuildRoot $(buildOutDir)\LocalhostWebServer -StaticFileRoot $(Agent.TempDirectory)\TestLocalIndex -CertPath $(HTTPSDevCert.secureFilePath) -CertPassword microsoft' - - - task: PowerShell@2 displayName: 'Set program files directory' inputs: targetType: 'inline' @@ -240,12 +216,10 @@ jobs: TargetFolder: '$(platformProgramFiles)\dotnet' Contents: Microsoft.Management.Deployment.winmd - - task: PowerShell@2 - displayName: Setup Local PS Repository - inputs: - filePath: 'src\AppInstallerCLIE2ETests\TestData\Configuration\Init-TestRepository.ps1' - arguments: '-Force' - pwsh: true + - template: templates/e2e-setup.yml + parameters: + source: $(Build.SourcesDirectory) + buildOutDir: $(buildOutDir) - template: templates/e2e-test.template.yml parameters: @@ -269,7 +243,21 @@ jobs: displayName: 'Copy E2E Tests Package Log to artifacts folder' inputs: SourceFolder: '$(temp)\E2ETestLogs' - TargetFolder: '$(artifactsDir)\E2ETestsPackagedLog' + TargetFolder: '$(artifactsDir)\E2ETests\PackagedLog' + condition: succeededOrFailed() + + - task: CopyFiles@2 + displayName: 'Copy E2E Test Source' + inputs: + SourceFolder: '$(Agent.TempDirectory)\TestLocalIndex' + TargetFolder: '$(artifactsDir)\E2ETests\TestLocalIndex' + condition: succeededOrFailed() + + - task: CopyFiles@2 + displayName: 'Copy TestData' + inputs: + SourceFolder: '$(Build.SourcesDirectory)\src\AppInstallerCLIE2ETests\TestData\' + TargetFolder: '$(artifactsDir)\E2ETests\TestData' condition: succeededOrFailed() - task: CopyFiles@2 diff --git a/src/AppInstallerCLI.sln b/src/AppInstallerCLI.sln @@ -175,6 +175,8 @@ Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Microsoft.Management.Config {2B00D362-AC92-41F3-A8D2-5B1599BDCA01} = {2B00D362-AC92-41F3-A8D2-5B1599BDCA01} EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WinGetSourceCreator", "WinGetSourceCreator\WinGetSourceCreator.csproj", "{52EC37D6-088C-40D3-AD0B-BDE8F8DAF9EB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|ARM64 = Debug|ARM64 @@ -1166,6 +1168,36 @@ Global {2268D5AD-7F2A-485A-8C4B-C574497514C9}.TestRelease|x64.Build.0 = Release|x64 {2268D5AD-7F2A-485A-8C4B-C574497514C9}.TestRelease|x86.ActiveCfg = Release|Win32 {2268D5AD-7F2A-485A-8C4B-C574497514C9}.TestRelease|x86.Build.0 = Release|Win32 + {52EC37D6-088C-40D3-AD0B-BDE8F8DAF9EB}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {52EC37D6-088C-40D3-AD0B-BDE8F8DAF9EB}.Debug|ARM64.Build.0 = Debug|Any CPU + {52EC37D6-088C-40D3-AD0B-BDE8F8DAF9EB}.Debug|x64.ActiveCfg = Debug|Any CPU + {52EC37D6-088C-40D3-AD0B-BDE8F8DAF9EB}.Debug|x64.Build.0 = Debug|Any CPU + {52EC37D6-088C-40D3-AD0B-BDE8F8DAF9EB}.Debug|x86.ActiveCfg = Debug|Any CPU + {52EC37D6-088C-40D3-AD0B-BDE8F8DAF9EB}.Debug|x86.Build.0 = Debug|Any CPU + {52EC37D6-088C-40D3-AD0B-BDE8F8DAF9EB}.Fuzzing|ARM64.ActiveCfg = Release|Any CPU + {52EC37D6-088C-40D3-AD0B-BDE8F8DAF9EB}.Fuzzing|ARM64.Build.0 = Release|Any CPU + {52EC37D6-088C-40D3-AD0B-BDE8F8DAF9EB}.Fuzzing|x64.ActiveCfg = Release|Any CPU + {52EC37D6-088C-40D3-AD0B-BDE8F8DAF9EB}.Fuzzing|x64.Build.0 = Release|Any CPU + {52EC37D6-088C-40D3-AD0B-BDE8F8DAF9EB}.Fuzzing|x86.ActiveCfg = Release|Any CPU + {52EC37D6-088C-40D3-AD0B-BDE8F8DAF9EB}.Fuzzing|x86.Build.0 = Release|Any CPU + {52EC37D6-088C-40D3-AD0B-BDE8F8DAF9EB}.PowerShell|ARM64.ActiveCfg = Release|Any CPU + {52EC37D6-088C-40D3-AD0B-BDE8F8DAF9EB}.PowerShell|ARM64.Build.0 = Release|Any CPU + {52EC37D6-088C-40D3-AD0B-BDE8F8DAF9EB}.PowerShell|x64.ActiveCfg = Release|Any CPU + {52EC37D6-088C-40D3-AD0B-BDE8F8DAF9EB}.PowerShell|x64.Build.0 = Release|Any CPU + {52EC37D6-088C-40D3-AD0B-BDE8F8DAF9EB}.PowerShell|x86.ActiveCfg = Release|Any CPU + {52EC37D6-088C-40D3-AD0B-BDE8F8DAF9EB}.PowerShell|x86.Build.0 = Release|Any CPU + {52EC37D6-088C-40D3-AD0B-BDE8F8DAF9EB}.Release|ARM64.ActiveCfg = Release|Any CPU + {52EC37D6-088C-40D3-AD0B-BDE8F8DAF9EB}.Release|ARM64.Build.0 = Release|Any CPU + {52EC37D6-088C-40D3-AD0B-BDE8F8DAF9EB}.Release|x64.ActiveCfg = Release|Any CPU + {52EC37D6-088C-40D3-AD0B-BDE8F8DAF9EB}.Release|x64.Build.0 = Release|Any CPU + {52EC37D6-088C-40D3-AD0B-BDE8F8DAF9EB}.Release|x86.ActiveCfg = Release|Any CPU + {52EC37D6-088C-40D3-AD0B-BDE8F8DAF9EB}.Release|x86.Build.0 = Release|Any CPU + {52EC37D6-088C-40D3-AD0B-BDE8F8DAF9EB}.TestRelease|ARM64.ActiveCfg = Release|Any CPU + {52EC37D6-088C-40D3-AD0B-BDE8F8DAF9EB}.TestRelease|ARM64.Build.0 = Release|Any CPU + {52EC37D6-088C-40D3-AD0B-BDE8F8DAF9EB}.TestRelease|x64.ActiveCfg = Release|Any CPU + {52EC37D6-088C-40D3-AD0B-BDE8F8DAF9EB}.TestRelease|x64.Build.0 = Release|Any CPU + {52EC37D6-088C-40D3-AD0B-BDE8F8DAF9EB}.TestRelease|x86.ActiveCfg = Release|Any CPU + {52EC37D6-088C-40D3-AD0B-BDE8F8DAF9EB}.TestRelease|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/AppInstallerCLIE2ETests/AppInstallerCLIE2ETests.csproj b/src/AppInstallerCLIE2ETests/AppInstallerCLIE2ETests.csproj @@ -62,6 +62,7 @@ <None Remove="testdata\configuration\ResourcesNotASequence.yml" /> <None Remove="testdata\configuration\UnitNotAMap.yml" /> <None Remove="testdata\configuration\UnknownVersion.yml" /> + <None Remove="TestData\localsource.json" /> </ItemGroup> <ItemGroup> @@ -80,6 +81,7 @@ <ProjectReference Include="..\WindowsPackageManager\WindowsPackageManager.vcxproj"> <ReferenceOutputAssembly>False</ReferenceOutputAssembly> </ProjectReference> + <ProjectReference Include="..\WinGetSourceCreator\WinGetSourceCreator.csproj" /> <ProjectReference Include="..\WinGetUtil\WinGetUtil.vcxproj"> <ReferenceOutputAssembly>False</ReferenceOutputAssembly> </ProjectReference> diff --git a/src/AppInstallerCLIE2ETests/AppShutdownTests.cs b/src/AppInstallerCLIE2ETests/AppShutdownTests.cs @@ -11,12 +11,13 @@ namespace AppInstallerCLIE2ETests using System.Threading; using System.Threading.Tasks; using System.Xml; + using AppInstallerCLIE2ETests.Helpers; using NUnit.Framework; /// <summary> /// `test appshutdown` command tests. /// </summary> - public class AppShutdownTests : BaseCommand + public class AppShutdownTests { /// <summary> /// Runs winget test appshutdown and register the application to force a WM_QUERYENDSESSION message. @@ -25,17 +26,17 @@ namespace AppInstallerCLIE2ETests [Ignore("This test won't work on Window Server")] public void RegisterApplicationTest() { - if (!TestCommon.PackagedContext) + if (!TestSetup.Parameters.PackagedContext) { - return; + Assert.Ignore("Not packaged context."); } - if (string.IsNullOrEmpty(TestCommon.AICLIPackagePath)) + if (string.IsNullOrEmpty(TestSetup.Parameters.AICLIPackagePath)) { throw new NullReferenceException("AICLIPackagePath"); } - var appxManifest = Path.Combine(TestCommon.AICLIPackagePath, "AppxManifest.xml"); + var appxManifest = Path.Combine(TestSetup.Parameters.AICLIPackagePath, "AppxManifest.xml"); if (!File.Exists(appxManifest)) { throw new FileNotFoundException(appxManifest); @@ -73,7 +74,7 @@ namespace AppInstallerCLIE2ETests // Register the app with the updated version. var registerTask = new Task<bool>(() => { - return TestCommon.InstallMsixRegister(TestCommon.AICLIPackagePath, true, false); + return TestCommon.InstallMsixRegister(TestSetup.Parameters.AICLIPackagePath, true, false); }); // Give it a little time. @@ -97,12 +98,12 @@ namespace AppInstallerCLIE2ETests [Test] public void RegisterApplicationTest_Force() { - if (!TestCommon.PackagedContext) + if (!TestSetup.Parameters.PackagedContext) { - return; + Assert.Ignore("Not packaged context."); } - if (string.IsNullOrEmpty(TestCommon.AICLIPackagePath)) + if (string.IsNullOrEmpty(TestSetup.Parameters.AICLIPackagePath)) { throw new NullReferenceException("AICLIPackagePath"); } diff --git a/src/AppInstallerCLIE2ETests/BaseCommand.cs b/src/AppInstallerCLIE2ETests/BaseCommand.cs @@ -8,6 +8,7 @@ namespace AppInstallerCLIE2ETests { using System; using System.IO; + using AppInstallerCLIE2ETests.Helpers; using Newtonsoft.Json.Linq; using NUnit.Framework; diff --git a/src/AppInstallerCLIE2ETests/ConfigureCommand.cs b/src/AppInstallerCLIE2ETests/ConfigureCommand.cs @@ -7,6 +7,7 @@ namespace AppInstallerCLIE2ETests { using System.IO; + using AppInstallerCLIE2ETests.Helpers; using NUnit.Framework; /// <summary> diff --git a/src/AppInstallerCLIE2ETests/ConfigureShowCommand.cs b/src/AppInstallerCLIE2ETests/ConfigureShowCommand.cs @@ -6,6 +6,7 @@ namespace AppInstallerCLIE2ETests { + using AppInstallerCLIE2ETests.Helpers; using NUnit.Framework; /// <summary> diff --git a/src/AppInstallerCLIE2ETests/ConfigureTestCommand.cs b/src/AppInstallerCLIE2ETests/ConfigureTestCommand.cs @@ -7,6 +7,7 @@ namespace AppInstallerCLIE2ETests { using System.IO; + using AppInstallerCLIE2ETests.Helpers; using Microsoft.Management.Infrastructure; using NUnit.Framework; diff --git a/src/AppInstallerCLIE2ETests/ConfigureValidateCommand.cs b/src/AppInstallerCLIE2ETests/ConfigureValidateCommand.cs @@ -6,6 +6,7 @@ namespace AppInstallerCLIE2ETests { + using AppInstallerCLIE2ETests.Helpers; using NUnit.Framework; /// <summary> diff --git a/src/AppInstallerCLIE2ETests/Constants.cs b/src/AppInstallerCLIE2ETests/Constants.cs @@ -22,13 +22,13 @@ namespace AppInstallerCLIE2ETests public const string LooseFileRegistrationParameter = "LooseFileRegistration"; public const string InvokeCommandInDesktopPackageParameter = "InvokeCommandInDesktopPackage"; public const string StaticFileRootPathParameter = "StaticFileRootPath"; + public const string LocalServerCertPathParameter = "LocalServerCertPath"; public const string ExeInstallerPathParameter = "ExeTestInstallerPath"; public const string MsiInstallerPathParameter = "MsiTestInstallerPath"; public const string MsixInstallerPathParameter = "MsixTestInstallerPath"; public const string PackageCertificatePathParameter = "PackageCertificatePath"; public const string PowerShellModulePathParameter = "PowerShellModulePath"; - public const string AppInstallerTestCert = "AppInstallerTest.cer"; - public const string AppInstallerTestCertThumbprint = "d03e7a688b388b1edde8476a627531c49db88017"; + public const string SkipTestSourceParameter = "SkipTestSource"; // Test Sources public const string DefaultWingetSourceName = @"winget"; @@ -42,7 +42,9 @@ namespace AppInstallerCLIE2ETests public const string TestSourceUrl = @"https://localhost:5001/TestKit"; public const string TestSourceType = "Microsoft.PreIndexed.Package"; public const string TestSourceIdentifier = @"WingetE2E.Tests_8wekyb3d8bbwe"; - public const string TestSourceServerCertificateFileName = "servercert.cer"; + + public const string AppInstallerTestCert = "AppInstallerTest.cer"; + public const string AppInstallerTestCertThumbprint = "d03e7a688b388b1edde8476a627531c49db88017"; public const string AICLIPackageFamilyName = "WinGetDevCLI_8wekyb3d8bbwe"; public const string AICLIPackageName = "WinGetDevCLI"; diff --git a/src/AppInstallerCLIE2ETests/DownloadCommand.cs b/src/AppInstallerCLIE2ETests/DownloadCommand.cs @@ -6,7 +6,8 @@ namespace AppInstallerCLIE2ETests { - using System.IO; + using System.IO; + using AppInstallerCLIE2ETests.Helpers; using Microsoft.Management.Deployment; using NUnit.Framework; using Windows.System; diff --git a/src/AppInstallerCLIE2ETests/FeaturesCommand.cs b/src/AppInstallerCLIE2ETests/FeaturesCommand.cs @@ -6,6 +6,7 @@ namespace AppInstallerCLIE2ETests { + using AppInstallerCLIE2ETests.Helpers; using NUnit.Framework; /// <summary> diff --git a/src/AppInstallerCLIE2ETests/GroupPolicy.cs b/src/AppInstallerCLIE2ETests/GroupPolicy.cs @@ -6,6 +6,7 @@ namespace AppInstallerCLIE2ETests { + using AppInstallerCLIE2ETests.Helpers; using NUnit.Framework; /// <summary> diff --git a/src/AppInstallerCLIE2ETests/GroupPolicyHelper.cs b/src/AppInstallerCLIE2ETests/GroupPolicyHelper.cs @@ -11,6 +11,7 @@ namespace AppInstallerCLIE2ETests using System.IO; using System.Linq; using System.Xml.Linq; + using AppInstallerCLIE2ETests.Helpers; using Microsoft.Win32; using Newtonsoft.Json; using NUnit.Framework; diff --git a/src/AppInstallerCLIE2ETests/HashCommand.cs b/src/AppInstallerCLIE2ETests/HashCommand.cs @@ -6,6 +6,7 @@ namespace AppInstallerCLIE2ETests { + using AppInstallerCLIE2ETests.Helpers; using NUnit.Framework; using NUnit.Framework.Internal; diff --git a/src/AppInstallerCLIE2ETests/Helpers/TestCommon.cs b/src/AppInstallerCLIE2ETests/Helpers/TestCommon.cs @@ -0,0 +1,922 @@ +// ----------------------------------------------------------------------------- +// <copyright file="TestCommon.cs" company="Microsoft Corporation"> +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +// </copyright> +// ----------------------------------------------------------------------------- + +namespace AppInstallerCLIE2ETests.Helpers +{ + using System; + using System.Diagnostics; + using System.IO; + using System.Reflection; + using System.Threading; + using AppInstallerCLIE2ETests; + using Microsoft.Management.Deployment; + using Microsoft.Win32; + using NUnit.Framework; + + /// <summary> + /// Test common. + /// </summary> + public static class TestCommon + { + /// <summary> + /// Scope. + /// </summary> + public enum Scope + { + /// <summary> + /// None. + /// </summary> + Unknown, + + /// <summary> + /// User. + /// </summary> + User, + + /// <summary> + /// Machine. + /// </summary> + Machine, + } + + /// <summary> + /// Run winget command. + /// </summary> + /// <param name="command">Command to run.</param> + /// <param name="parameters">Parameters.</param> + /// <param name="stdIn">Optional std in.</param> + /// <param name="timeOut">Optional timeout.</param> + /// <returns>The result of the command.</returns> + public static RunCommandResult RunAICLICommand(string command, string parameters, string stdIn = null, int timeOut = 60000) + { + string inputMsg = + "AICLI path: " + TestSetup.Parameters.AICLIPath + + " Command: " + command + + " Parameters: " + parameters + + (string.IsNullOrEmpty(stdIn) ? string.Empty : " StdIn: " + stdIn) + + " Timeout: " + timeOut; + + TestContext.Out.WriteLine($"Starting command run. {inputMsg} InvokeCommandInDesktopPackage: {TestSetup.Parameters.InvokeCommandInDesktopPackage}"); + + if (TestSetup.Parameters.InvokeCommandInDesktopPackage) + { + return RunAICLICommandViaInvokeCommandInDesktopPackage(command, parameters, stdIn, timeOut); + } + else + { + return RunAICLICommandViaDirectProcess(command, parameters, stdIn, timeOut); + } + } + + /// <summary> + /// Run winget command via direct process. + /// </summary> + /// <param name="command">Command to run.</param> + /// <param name="parameters">Parameters.</param> + /// <param name="stdIn">Optional std in.</param> + /// <param name="timeOut">Optional timeout.</param> + /// <returns>The result of the command.</returns> + public static RunCommandResult RunAICLICommandViaDirectProcess(string command, string parameters, string stdIn = null, int timeOut = 60000) + { + RunCommandResult result = new (); + Process p = new Process(); + p.StartInfo = new ProcessStartInfo(TestSetup.Parameters.AICLIPath, command + ' ' + parameters); + p.StartInfo.UseShellExecute = false; + p.StartInfo.RedirectStandardOutput = true; + p.StartInfo.RedirectStandardError = true; + + if (!string.IsNullOrEmpty(stdIn)) + { + p.StartInfo.RedirectStandardInput = true; + } + + p.Start(); + + if (!string.IsNullOrEmpty(stdIn)) + { + p.StandardInput.Write(stdIn); + } + + if (p.WaitForExit(timeOut)) + { + result.ExitCode = p.ExitCode; + result.StdOut = p.StandardOutput.ReadToEnd(); + result.StdErr = p.StandardError.ReadToEnd(); + + TestContext.Out.WriteLine("Command run completed with exit code: " + result.ExitCode); + + if (!string.IsNullOrEmpty(result.StdErr)) + { + TestContext.Error.WriteLine("Command run error. Error: " + result.StdErr); + } + + if (TestSetup.Parameters.VerboseLogging && !string.IsNullOrEmpty(result.StdOut)) + { + TestContext.Out.WriteLine("Command run output. Output: " + result.StdOut); + } + } + else + { + throw new TimeoutException($"Direct winget command run timed out: {command} {parameters}"); + } + + return result; + } + + /// <summary> + /// This method is used when the test is run in an OS that does not support AppExecutionAlias. E,g, our build machine. + /// There is not any existing API that'll activate a packaged app and wait for result, and not possible to capture the stdIn and stdOut. + /// This method tries to call Invoke-CommandInDesktopPackage PS command to make test executable run in packaged context. + /// Since Invoke-CommandInDesktopPackage just launches the executable and return, we use cmd pipe to get execution results. + /// The final constructed command will look like: + /// Invoke-CommandInDesktopPackage ...... -Command cmd.exe -Args '-c [cmd command]' + /// where [cmd command] will look like: "echo stdIn | appinst.exe args > stdout.txt 2> stderr.txt &amp; echo %ERRORLEVEL% > exitcode.txt" + /// Then this method will read the piped result and return as RunCommandResult. + /// </summary> + /// <param name="command">Command to run.</param> + /// <param name="parameters">Parameters.</param> + /// <param name="stdIn">Optional std in.</param> + /// <param name="timeOut">Optional timeout.</param> + /// <param name="throwOnTimeout">Throw on timeout.</param> + /// <returns>The result of the command.</returns> + public static RunCommandResult RunAICLICommandViaInvokeCommandInDesktopPackage(string command, string parameters, string stdIn = null, int timeOut = 60000, bool throwOnTimeout = true) + { + string cmdCommandPiped = string.Empty; + if (!string.IsNullOrEmpty(stdIn)) + { + cmdCommandPiped += $"echo {stdIn} | "; + } + + string workDirectory = GetRandomTestDir(); + string tempBatchFile = Path.Combine(workDirectory, "Batch.cmd"); + string exitCodeFile = Path.Combine(workDirectory, "ExitCode.txt"); + string stdOutFile = Path.Combine(workDirectory, "StdOut.txt"); + string stdErrFile = Path.Combine(workDirectory, "StdErr.txt"); + + // First change the codepage so that the rest of the batch file works + cmdCommandPiped += $"chcp 65001\n{TestSetup.Parameters.AICLIPath} {command} {parameters} > {stdOutFile} 2> {stdErrFile}\necho %ERRORLEVEL% > {exitCodeFile}"; + File.WriteAllText(tempBatchFile, cmdCommandPiped, new System.Text.UTF8Encoding(false)); + + string psCommand = $"Invoke-CommandInDesktopPackage -PackageFamilyName {Constants.AICLIPackageFamilyName} -AppId {Constants.AICLIAppId} -PreventBreakaway -Command cmd.exe -Args '/c \"{tempBatchFile}\"'"; + + var psInvokeResult = RunCommandWithResult("powershell", psCommand); + + if (psInvokeResult.ExitCode != 0) + { + // PS invocation failed, return result and no need to check piped output. + return psInvokeResult; + } + + // The PS command just launches the app and immediately returns, we'll have to wait for up to the timeOut specified here + int waitedTime = 0; + while (!File.Exists(exitCodeFile) && waitedTime <= timeOut) + { + Thread.Sleep(1000); + waitedTime += 1000; + } + + if (waitedTime >= timeOut && throwOnTimeout) + { + throw new TimeoutException($"Packaged winget command run timed out: {command} {parameters}"); + } + + RunCommandResult result = new (); + + // Sometimes the files are still in use; allow for this with a wait and retry loop. + for (int retryCount = 0; retryCount < 4; ++retryCount) + { + bool success = false; + + try + { + result.ExitCode = File.Exists(exitCodeFile) ? int.Parse(File.ReadAllText(exitCodeFile).Trim()) : unchecked((int)0x80004005); + result.StdOut = File.Exists(stdOutFile) ? File.ReadAllText(stdOutFile) : string.Empty; + result.StdErr = File.Exists(stdErrFile) ? File.ReadAllText(stdErrFile) : string.Empty; + success = true; + } + catch (Exception e) + { + TestContext.Out.WriteLine("Failed to access files: " + e.Message); + } + + if (success) + { + break; + } + else + { + Thread.Sleep(250); + } + } + + return result; + } + + /// <summary> + /// Run command. + /// </summary> + /// <param name="fileName">File name.</param> + /// <param name="args">Args.</param> + /// <param name="timeOut">Time out.</param> + /// <param name="throwOnFailure">If true, throw instead of returning false on a failure.</param> + /// <returns>True if exit code is 0.</returns> + public static bool RunCommand(string fileName, string args = "", int timeOut = 60000, bool throwOnFailure = false) + { + RunCommandResult result = RunCommandWithResult(fileName, args, timeOut); + + if (result.ExitCode != 0) + { + TestContext.Out.WriteLine($"Command failed with: {result.ExitCode}"); + if (throwOnFailure) + { + throw new RunCommandException(fileName, args, result); + } + + return false; + } + else + { + return true; + } + } + + /// <summary> + /// Run command with result. + /// </summary> + /// <param name="fileName">File name.</param> + /// <param name="args">Args.</param> + /// <param name="timeOut">Optional timeout.</param> + /// <returns>Command result.</returns> + public static RunCommandResult RunCommandWithResult(string fileName, string args, int timeOut = 60000) + { + TestContext.Out.WriteLine($"Running command: {fileName} {args}"); + + Process p = new Process(); + p.StartInfo = new ProcessStartInfo(fileName, args); + p.StartInfo.RedirectStandardOutput = true; + p.StartInfo.RedirectStandardError = true; + p.Start(); + + RunCommandResult result = new (); + if (p.WaitForExit(timeOut)) + { + result.ExitCode = p.ExitCode; + result.StdOut = p.StandardOutput.ReadToEnd(); + result.StdErr = p.StandardError.ReadToEnd(); + + if (TestSetup.Parameters.VerboseLogging) + { + TestContext.Out.WriteLine($"Command run finished. {fileName} {args} {timeOut}. Output: {result.StdOut} Error: {result.StdErr}"); + } + } + else + { + throw new TimeoutException($"Command run timed out. {fileName} {args} {timeOut}"); + } + + return result; + } + + /// <summary> + /// Run PowerShell Core command with result. + /// </summary> + /// <param name="cmdlet">Cmdlet to run.</param> + /// <param name="args">Args.</param> + /// <param name="timeOut">Optional timeout.</param> + /// <returns>Command result.</returns> + public static RunCommandResult RunPowerShellCoreCommandWithResult(string cmdlet, string args, int timeOut = 60000) + { + return RunCommandWithResult("pwsh.exe", $"-Command ipmo {TestSetup.Parameters.PowerShellModuleManifestPath}; {cmdlet} {args}", timeOut); + } + + /// <summary> + /// Get test file path. + /// </summary> + /// <param name="fileName">Test file name.</param> + /// <returns>Path of test file.</returns> + public static string GetTestFile(string fileName) + { + return Path.Combine(TestContext.CurrentContext.TestDirectory, fileName); + } + + /// <summary> + /// Get test data file path. + /// </summary> + /// <param name="fileName">File name.</param> + /// <returns>Test file data path.</returns> + public static string GetTestDataFile(string fileName) + { + return GetTestFile(Path.Combine("TestData", fileName)); + } + + /// <summary> + /// Get test work directory. Creates if not exists. + /// </summary> + /// <returns>The work directory.</returns> + public static string GetTestWorkDir() + { + string workDir = Path.Combine(TestContext.CurrentContext.TestDirectory, "WorkDirectory"); + Directory.CreateDirectory(workDir); + return workDir; + } + + /// <summary> + /// Create random test directory. + /// </summary> + /// <returns>Path of new test directory.</returns> + public static string GetRandomTestDir() + { + string randDir = Path.Combine(GetTestWorkDir(), Path.GetRandomFileName()); + Directory.CreateDirectory(randDir); + return randDir; + } + + /// <summary> + /// Creates new random file name. File is not created. + /// </summary> + /// <param name="extension">Extension of random file.</param> + /// <returns>Path of random file.</returns> + public static string GetRandomTestFile(string extension) + { + return Path.Combine(GetTestWorkDir(), Path.GetRandomFileName() + extension); + } + + /// <summary> + /// Install msix package via PowerShell. + /// </summary> + /// <param name="file">Msix file.</param> + /// <returns>True if installed.</returns> + public static bool InstallMsix(string file) + { + return RunCommand("powershell", $"Add-AppxPackage \"{file}\"", throwOnFailure: true); + } + + /// <summary> + /// Install and register msix package via appx manifest. + /// </summary> + /// <param name="packagePath">Path to package.</param> + /// <param name="forceShutdown">Force shutdown.</param> + /// <param name="throwOnFailure">Throw on failure.</param> + /// <returns>True if installed correctly.</returns> + public static bool InstallMsixRegister(string packagePath, bool forceShutdown = false, bool throwOnFailure = true) + { + string manifestFile = Path.Combine(packagePath, "AppxManifest.xml"); + + var command = $"Add-AppxPackage -Register \"{manifestFile}\""; + if (forceShutdown) + { + command += " -ForceTargetApplicationShutdown"; + } + + return RunCommand("powershell", command, throwOnFailure: throwOnFailure); + } + + /// <summary> + /// Remove msix package. + /// </summary> + /// <param name="name">Package to remove.</param> + /// <param name="isProvisioned">Whether the package is provisioned.</param> + /// <returns>True if removed correctly.</returns> + public static bool RemoveMsix(string name, bool isProvisioned = false) + { + if (isProvisioned) + { + return RunCommand("powershell", $"Get-AppxProvisionedPackage -Online | Where-Object {{$_.PackageName -like \"*{name}*\"}} | Remove-AppxProvisionedPackage -Online -AllUsers") && + RunCommand("powershell", $"Get-AppxPackage \"{name}\" | Remove-AppxPackage -AllUsers"); + } + else + { + return RunCommand("powershell", $"Get-AppxPackage \"{name}\" | Remove-AppxPackage"); + } + } + + /// <summary> + /// Gets the portable symlink directory. + /// </summary> + /// <param name="scope">Scope.</param> + /// <returns>The path of the symlinks.</returns> + public static string GetPortableSymlinkDirectory(Scope scope) + { + if (scope == Scope.User) + { + return Path.Combine(Environment.GetEnvironmentVariable("LocalAppData"), "Microsoft", "WinGet", "Links"); + } + else + { + return Path.Combine(Environment.GetEnvironmentVariable("ProgramFiles"), "WinGet", "Links"); + } + } + + /// <summary> + /// Gets the portable package directory. + /// </summary> + /// <returns>The portable package directory.</returns> + public static string GetPortablePackagesDirectory() + { + return Path.Combine(Environment.GetEnvironmentVariable("LocalAppData"), "Microsoft", "WinGet", "Packages"); + } + + /// <summary> + /// Gets the default download directory for the download command. + /// </summary> + /// <returns>The default download directory.</returns> + public static string GetDefaultDownloadDirectory() + { + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"); + } + + /// <summary> + /// Verify portable package. + /// </summary> + /// <param name="installDir">Install dir.</param> + /// <param name="commandAlias">Command alias.</param> + /// <param name="filename">File name.</param> + /// <param name="productCode">Product code.</param> + /// <param name="shouldExist">Should exists.</param> + /// <param name="scope">Scope.</param> + public static void VerifyPortablePackage( + string installDir, + string commandAlias, + string filename, + string productCode, + bool shouldExist, + Scope scope = Scope.User) + { + string exePath = Path.Combine(installDir, filename); + bool exeExists = File.Exists(exePath); + + string symlinkDirectory = GetPortableSymlinkDirectory(scope); + string symlinkPath = Path.Combine(symlinkDirectory, commandAlias); + bool symlinkExists = File.Exists(symlinkPath); + + bool portableEntryExists; + RegistryKey baseKey = scope == Scope.User ? Registry.CurrentUser : Registry.LocalMachine; + string uninstallSubKey = Constants.UninstallSubKey; + using (RegistryKey uninstallRegistryKey = baseKey.OpenSubKey(uninstallSubKey, true)) + { + RegistryKey portableEntry = uninstallRegistryKey.OpenSubKey(productCode, true); + portableEntryExists = portableEntry != null; + } + + bool isAddedToPath; + string pathSubKey = scope == Scope.User ? Constants.PathSubKey_User : Constants.PathSubKey_Machine; + using (RegistryKey environmentRegistryKey = baseKey.OpenSubKey(pathSubKey, true)) + { + string pathName = "Path"; + var currentPathValue = (string)environmentRegistryKey.GetValue(pathName); + var portablePathValue = symlinkDirectory + ';'; + isAddedToPath = currentPathValue.Contains(portablePathValue); + } + + if (shouldExist) + { + RunAICLICommand("uninstall", $"--product-code {productCode} --force"); + } + + Assert.AreEqual(shouldExist, exeExists, $"Expected portable exe path: {exePath}"); + Assert.AreEqual(shouldExist, symlinkExists, $"Expected portable symlink path: {symlinkPath}"); + Assert.AreEqual(shouldExist, portableEntryExists, $"Expected {productCode} subkey in path: {uninstallSubKey}"); + Assert.AreEqual(shouldExist, isAddedToPath, $"Expected path variable: {symlinkDirectory}"); + } + + /// <summary> + /// Copies log files to the path %TEMP%\E2ETestLogs. + /// </summary> + public static void PublishE2ETestLogs() + { + string tempPath = Path.GetTempPath(); + string localAppDataPath = Environment.GetEnvironmentVariable("LocalAppData"); + string testLogsPackagedSourcePath = Path.Combine(localAppDataPath, Constants.E2ETestLogsPathPackaged); + string testLogsUnpackagedSourcePath = Path.Combine(tempPath, Constants.E2ETestLogsPathUnpackaged); + string testLogsDestPath = Path.Combine(tempPath, "E2ETestLogs"); + string testLogsPackagedDestPath = Path.Combine(testLogsDestPath, "Packaged"); + string testLogsUnpackagedDestPath = Path.Combine(testLogsDestPath, "Unpackaged"); + + if (Directory.Exists(testLogsPackagedSourcePath)) + { + CopyDirectory(testLogsPackagedSourcePath, testLogsPackagedDestPath); + } + + if (Directory.Exists(testLogsUnpackagedSourcePath)) + { + CopyDirectory(testLogsUnpackagedSourcePath, testLogsUnpackagedDestPath); + } + } + + /// <summary> + /// Gets the server certificate as a hex string. + /// </summary> + /// <returns>Hex string.</returns> + public static string GetTestServerCertificateHexString() + { + if (string.IsNullOrEmpty(TestSetup.Parameters.LocalServerCertPath)) + { + throw new Exception($"{Constants.LocalServerCertPathParameter} not set."); + } + + if (!File.Exists(TestSetup.Parameters.LocalServerCertPath)) + { + throw new FileNotFoundException(TestSetup.Parameters.LocalServerCertPath); + } + + return Convert.ToHexString(File.ReadAllBytes(TestSetup.Parameters.LocalServerCertPath)); + } + + /// <summary> + /// Verify exe installer correctly. + /// </summary> + /// <param name="installDir">Install directory.</param> + /// <param name="expectedContent">Optional expected content.</param> + /// <returns>True if success.</returns> + public static bool VerifyTestExeInstalled(string installDir, string expectedContent = null) + { + bool verifyInstallSuccess = true; + + if (!File.Exists(Path.Combine(installDir, Constants.TestExeInstalledFileName))) + { + TestContext.Out.WriteLine($"TestExeInstalled.exe not found at {installDir}"); + verifyInstallSuccess = false; + } + + if (verifyInstallSuccess && !string.IsNullOrEmpty(expectedContent)) + { + string content = File.ReadAllText(Path.Combine(installDir, Constants.TestExeInstalledFileName)); + TestContext.Out.WriteLine($"TestExeInstalled.exe content: {content}"); + verifyInstallSuccess = content.Contains(expectedContent); + } + + return verifyInstallSuccess; + } + + /// <summary> + /// Verify installer downloaded correctly and cleanup. + /// </summary> + /// <param name="downloadDir">Download directory.</param> + /// <param name="name">Package name.</param> + /// <param name="version">Package version.</param> + /// <param name="arch">Installer architecture.</param> + /// <param name="scope">Installer scope.</param> + /// <param name="installerType">Installer type.</param> + /// <param name="locale">Installer locale.</param> + /// <param name="isArchive">Boolean value indicating whether the installer is an archive.</param> + /// <param name="cleanup">Boolean value indicating whether to remove the installer file and directory.</param> + /// <returns>True if success.</returns> + public static bool VerifyInstallerDownload( + string downloadDir, + string name, + string version, + Windows.System.ProcessorArchitecture arch, + Scope scope, + PackageInstallerType installerType, + string locale = null, + bool isArchive = false, + bool cleanup = true) + { + string expectedFileName = $"{name}_{version}"; + + if (scope != Scope.Unknown) + { + expectedFileName += $"_{scope}"; + } + + expectedFileName += $"_{arch}_{installerType}"; + + if (!string.IsNullOrEmpty(locale)) + { + expectedFileName += $"_{locale}"; + } + + string extension; + if (isArchive) + { + extension = ".zip"; + } + else + { + extension = installerType switch + { + PackageInstallerType.Msi => ".msi", + PackageInstallerType.Msix => ".msix", + _ => ".exe" + }; + } + + expectedFileName += extension; + string installerDownloadPath = Path.Combine(downloadDir, expectedFileName); + + bool downloadResult = false; + + if (Directory.Exists(downloadDir) && File.Exists(installerDownloadPath)) + { + downloadResult = true; + + if (cleanup) + { + File.Delete(installerDownloadPath); + Directory.Delete(downloadDir, true); + } + } + + return downloadResult; + } + + /// <summary> + /// Verify exe installer correctly and then uninstall it. + /// </summary> + /// <param name="installDir">Install directory.</param> + /// <param name="expectedContent">Optional expected content.</param> + /// <returns>True if success.</returns> + public static bool VerifyTestExeInstalledAndCleanup(string installDir, string expectedContent = null) + { + bool verifyInstallSuccess = VerifyTestExeInstalled(installDir, expectedContent); + + // Always try clean up and ignore clean up failure + var uninstallerPath = Path.Combine(installDir, Constants.TestExeUninstallerFileName); + if (File.Exists(uninstallerPath)) + { + RunCommand(Path.Combine(installDir, Constants.TestExeUninstallerFileName)); + } + + return verifyInstallSuccess; + } + + /// <summary> + /// Verify msi installed correctly. + /// </summary> + /// <param name="installDir">Installed directory.</param> + /// <returns>True if success.</returns> + public static bool VerifyTestMsiInstalledAndCleanup(string installDir) + { + string pathToCheck = Path.Combine(installDir, Constants.AppInstallerTestExeInstallerExe); + if (!File.Exists(pathToCheck)) + { + TestContext.Out.WriteLine($"File not found: {pathToCheck}"); + return false; + } + + return RunCommand("msiexec.exe", $"/qn /x {Constants.MsiInstallerProductCode}"); + } + + /// <summary> + /// Verify msix installed correctly. + /// </summary> + /// <param name="isProvisioned">Whether the package is provisioned.</param> + /// <returns>True if success.</returns> + public static bool VerifyTestMsixInstalledAndCleanup(bool isProvisioned = false) + { + var result = RunCommandWithResult("powershell", $"Get-AppxPackage {Constants.MsixInstallerName}"); + + if (!result.StdOut.Contains(Constants.MsixInstallerName)) + { + return false; + } + + if (isProvisioned) + { + result = RunCommandWithResult("powershell", $"Get-AppxProvisionedPackage -Online | Where-Object {{$_.PackageName -like \"*{Constants.MsixInstallerName}*\"}}"); + if (!result.StdOut.Contains(Constants.MsixInstallerName)) + { + return false; + } + } + + return RemoveMsix(Constants.MsixInstallerName, isProvisioned); + } + + /// <summary> + /// Verify test exe uninstalled. + /// </summary> + /// <param name="installDir">Installed directory.</param> + /// <returns>True if success.</returns> + public static bool VerifyTestExeUninstalled(string installDir) + { + return File.Exists(Path.Combine(installDir, Constants.TestExeUninstalledFileName)); + } + + /// <summary> + /// Verify msi uninstalled. + /// </summary> + /// <param name="installDir">Install directory.</param> + /// <returns>True if success.</returns> + public static bool VerifyTestMsiUninstalled(string installDir) + { + return !File.Exists(Path.Combine(installDir, Constants.AppInstallerTestExeInstallerExe)); + } + + /// <summary> + /// Verify msix uninstalled. + /// </summary> + /// <param name="isProvisioned">Whether the package is provisioned.</param> + /// <returns>True if success.</returns> + public static bool VerifyTestMsixUninstalled(bool isProvisioned = false) + { + bool isUninstalled = false; + var result = RunCommandWithResult("powershell", $"Get-AppxPackage {Constants.MsixInstallerName}"); + isUninstalled = string.IsNullOrWhiteSpace(result.StdOut); + + if (isProvisioned) + { + result = RunCommandWithResult("powershell", $"Get-AppxProvisionedPackage -Online | Where-Object {{$_.PackageName -like \"*{Constants.MsixInstallerName}*\"}}"); + isUninstalled = isUninstalled && string.IsNullOrWhiteSpace(result.StdOut); + } + + return isUninstalled; + } + + /// <summary> + /// Modify uninstalled registry key. + /// </summary> + /// <param name="productCode">Product code.</param> + /// <param name="name">Name.</param> + /// <param name="value">Value.</param> + public static void ModifyPortableARPEntryValue(string productCode, string name, string value) + { + using (RegistryKey uninstallRegistryKey = Registry.CurrentUser.OpenSubKey(Constants.UninstallSubKey, true)) + { + RegistryKey entry = uninstallRegistryKey.OpenSubKey(productCode, true); + entry.SetValue(name, value); + } + } + + /// <summary> + /// Set up test source. + /// </summary> + /// <param name="useGroupPolicyForTestSource">Use group policy.</param> + public static void SetupTestSource(bool useGroupPolicyForTestSource = false) + { + RunAICLICommand("source reset", "--force"); + RunAICLICommand("source remove", Constants.DefaultWingetSourceName); + RunAICLICommand("source remove", Constants.DefaultMSStoreSourceName); + + // TODO: If/when cert pinning is implemented on the packaged index source, useGroupPolicyForTestSource should be set to default true + // to enable testing it by default. Until then, leaving this here... + if (useGroupPolicyForTestSource) + { + GroupPolicyHelper.EnableAdditionalSources.SetEnabledList(new GroupPolicyHelper.GroupPolicySource[] + { + new GroupPolicyHelper.GroupPolicySource + { + Name = Constants.TestSourceName, + Arg = Constants.TestSourceUrl, + Type = Constants.TestSourceType, + Data = Constants.TestSourceIdentifier, + Identifier = Constants.TestSourceIdentifier, + CertificatePinning = new GroupPolicyHelper.GroupPolicyCertificatePinning + { + Chains = new GroupPolicyHelper.GroupPolicyCertificatePinningChain[] + { + new GroupPolicyHelper.GroupPolicyCertificatePinningChain + { + Chain = new GroupPolicyHelper.GroupPolicyCertificatePinningDetails[] + { + new GroupPolicyHelper.GroupPolicyCertificatePinningDetails + { + Validation = new string[] { "publickey" }, + EmbeddedCertificate = GetTestServerCertificateHexString(), + }, + }, + }, + }, + }, + }, + }); + } + else + { + GroupPolicyHelper.EnableAdditionalSources.SetNotConfigured(); + RunAICLICommand("source add", $"{Constants.TestSourceName} {Constants.TestSourceUrl}"); + } + + Thread.Sleep(2000); + } + + /// <summary> + /// Tear down test source. + /// </summary> + public static void TearDownTestSource() + { + RunAICLICommand("source remove", Constants.TestSourceName); + RunAICLICommand("source reset", "--force"); + } + + /// <summary> + /// Ensures that a module is in the desired state. + /// </summary> + /// <param name="moduleName">The module.</param> + /// <param name="present">Whether the module is present or not.</param> + /// <param name="repository">The repository to get the module from if needed.</param> + public static void EnsureModuleState(string moduleName, bool present, string repository = null) + { + var result = RunCommandWithResult("pwsh", $"-Command \"Get-Module {moduleName} -ListAvailable\""); + bool isPresent = !string.IsNullOrWhiteSpace(result.StdOut); + + if (isPresent && !present) + { + RunCommand("pwsh", $"-Command \"Uninstall-Module {moduleName}\""); + } + else if (!isPresent && present) + { + if (string.IsNullOrEmpty(repository)) + { + RunCommand("pwsh", $"-Command \"Install-Module {moduleName} -Force\""); + } + else + { + RunCommand("pwsh", $"-Command \"Install-Module {moduleName} -Repository {repository} -Force\""); + } + } + } + + /// <summary> + /// Creates an ARP entry from the given values. + /// </summary> + /// <param name="productCode">Product code of the entry.</param> + /// <param name="properties">The properties to set in the entry.</param> + /// <param name="scope">Scope of the entry.</param> + public static void CreateARPEntry( + string productCode, + object properties, + Scope scope = Scope.User) + { + RegistryKey baseKey = scope == Scope.User ? Registry.CurrentUser : Registry.LocalMachine; + using (RegistryKey uninstallRegistryKey = baseKey.OpenSubKey(Constants.UninstallSubKey, true)) + { + RegistryKey entry = uninstallRegistryKey.CreateSubKey(productCode, true); + + foreach (PropertyInfo property in properties.GetType().GetProperties()) + { + entry.SetValue(property.Name, property.GetValue(properties)); + } + } + } + + /// <summary> + /// Removes an ARP entry. + /// </summary> + /// <param name="productCode">Product code of the entry.</param> + /// <param name="scope">Scope of the entry.</param> + public static void RemoveARPEntry( + string productCode, + Scope scope = Scope.User) + { + RegistryKey baseKey = scope == Scope.User ? Registry.CurrentUser : Registry.LocalMachine; + using (RegistryKey uninstallRegistryKey = baseKey.OpenSubKey(Constants.UninstallSubKey, true)) + { + uninstallRegistryKey.DeleteSubKey(productCode); + } + } + + /// <summary> + /// Copies the contents of a given directory from a source path to a destination path. + /// </summary> + /// <param name="sourceDirName">Source directory name.</param> + /// <param name="destDirName">Destination directory name.</param> + public static void CopyDirectory(string sourceDirName, string destDirName) + { + DirectoryInfo dir = new DirectoryInfo(sourceDirName); + DirectoryInfo[] dirs = dir.GetDirectories(); + + if (!Directory.Exists(destDirName)) + { + Directory.CreateDirectory(destDirName); + } + + FileInfo[] files = dir.GetFiles(); + foreach (FileInfo file in files) + { + string temppath = Path.Combine(destDirName, file.Name); + file.CopyTo(temppath, false); + } + + foreach (DirectoryInfo subdir in dirs) + { + string temppath = Path.Combine(destDirName, subdir.Name); + CopyDirectory(subdir.FullName, temppath); + } + } + + /// <summary> + /// Run command result. + /// </summary> + public struct RunCommandResult + { + /// <summary> + /// Exit code. + /// </summary> + public int ExitCode; + + /// <summary> + /// StdOut. + /// </summary> + public string StdOut; + + /// <summary> + /// StdErr. + /// </summary> + public string StdErr; + } + } +} diff --git a/src/AppInstallerCLIE2ETests/Helpers/TestIndex.cs b/src/AppInstallerCLIE2ETests/Helpers/TestIndex.cs @@ -0,0 +1,153 @@ +// ----------------------------------------------------------------------------- +// <copyright file="TestIndex.cs" company="Microsoft Corporation"> +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +// </copyright> +// ----------------------------------------------------------------------------- + +namespace AppInstallerCLIE2ETests.Helpers +{ + using System; + using System.IO; + using System.Text.Json; + using Microsoft.WinGetSourceCreator; + using WinGetSourceCreator.Model; + + /// <summary> + /// Test index setup. + /// </summary> + public static class TestIndex + { + static TestIndex() + { + // Expected path for the installers. + TestIndex.ExeInstaller = Path.Combine(TestSetup.Parameters.StaticFileRootPath, Constants.ExeInstaller, Constants.ExeInstallerFileName); + TestIndex.MsiInstaller = Path.Combine(TestSetup.Parameters.StaticFileRootPath, Constants.MsiInstaller, Constants.MsiInstallerFileName); + TestIndex.MsixInstaller = Path.Combine(TestSetup.Parameters.StaticFileRootPath, Constants.MsixInstaller, Constants.MsixInstallerFileName); + TestIndex.ZipInstaller = Path.Combine(TestSetup.Parameters.StaticFileRootPath, Constants.ZipInstaller, Constants.ZipInstallerFileName); + } + + /// <summary> + /// Gets the signed exe installer path used by the manifests in the E2E test. + /// </summary> + public static string ExeInstaller { get; private set; } + + /// <summary> + /// Gets the signed msi installer path used by the manifests in the E2E test. + /// </summary> + public static string MsiInstaller { get; private set; } + + /// <summary> + /// Gets the signed msix installer path used by the manifests in the E2E test. + /// </summary> + public static string MsixInstaller { get; private set; } + + /// <summary> + /// Gets the zip installer path used by the manifests in the E2E test. + /// </summary> + public static string ZipInstaller { get; private set; } + + /// <summary> + /// Generate test source. + /// </summary> + public static void GenerateE2ESource() + { + var testParams = TestSetup.Parameters; + + if (string.IsNullOrEmpty(testParams.ExeInstallerPath)) + { + throw new ArgumentNullException($"{Constants.ExeInstallerPathParameter} is required"); + } + + if (!File.Exists(testParams.ExeInstallerPath)) + { + throw new FileNotFoundException(testParams.ExeInstallerPath); + } + + if (string.IsNullOrEmpty(testParams.MsiInstallerPath)) + { + throw new ArgumentNullException($"{Constants.MsiInstallerPathParameter} is required"); + } + + if (!File.Exists(testParams.MsiInstallerPath)) + { + throw new FileNotFoundException(testParams.MsiInstallerPath); + } + + if (string.IsNullOrEmpty(testParams.MsixInstallerPath)) + { + throw new ArgumentNullException($"{Constants.MsixInstallerPathParameter} is required"); + } + + if (!File.Exists(testParams.MsixInstallerPath)) + { + throw new FileNotFoundException(testParams.MsixInstallerPath); + } + + if (string.IsNullOrEmpty(testParams.PackageCertificatePath)) + { + throw new ArgumentNullException($"{Constants.PackageCertificatePathParameter} is required"); + } + + if (!File.Exists(testParams.PackageCertificatePath)) + { + throw new FileNotFoundException(testParams.PackageCertificatePath); + } + + LocalSource e2eSource = new () + { + AppxManifest = TestCommon.GetTestDataFile(Path.Combine("Package", "AppxManifest.xml")), + WorkingDirectory = testParams.StaticFileRootPath, + LocalManifests = new () + { + TestCommon.GetTestDataFile("Manifests"), + }, + LocalInstallers = new () + { + new LocalInstaller + { + Type = InstallerType.Exe, + Name = Path.Combine(Constants.ExeInstaller, Constants.ExeInstallerFileName), + Input = testParams.ExeInstallerPath, + HashToken = "<EXEHASH>", + }, + new LocalInstaller + { + Type = InstallerType.Msi, + Name = Path.Combine(Constants.MsiInstaller, Constants.MsiInstallerFileName), + Input = testParams.MsiInstallerPath, + HashToken = "<MSIHASH>", + }, + new LocalInstaller + { + Type = InstallerType.Msix, + Name = Path.Combine(Constants.MsixInstaller, Constants.MsixInstallerFileName), + Input = testParams.MsixInstallerPath, + HashToken = "<MSIXHASH>", + SignatureToken = "<SIGNATUREHASH>", + }, + }, + DynamicInstallers = new () + { + new DynamicInstaller + { + Type = InstallerType.Zip, + Name = Path.Combine(Constants.ZipInstaller, Constants.ZipInstallerFileName), + Input = new () + { + ExeInstaller, + MsiInstaller, + MsixInstaller, + }, + HashToken = "<ZIPHASH>", + }, + }, + Signature = new () + { + CertFile = testParams.PackageCertificatePath, + }, + }; + + WinGetLocalSource.CreateLocalSource(e2eSource); + } + } +} diff --git a/src/AppInstallerCLIE2ETests/Helpers/TestSetup.cs b/src/AppInstallerCLIE2ETests/Helpers/TestSetup.cs @@ -0,0 +1,216 @@ +// ----------------------------------------------------------------------------- +// <copyright file="TestSetup.cs" company="Microsoft Corporation"> +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +// </copyright> +// ----------------------------------------------------------------------------- + +namespace AppInstallerCLIE2ETests.Helpers +{ + using System; + using System.IO; + using NUnit.Framework; + + /// <summary> + /// Singleton class with test parameters. + /// </summary> + internal class TestSetup + { + private static readonly Lazy<TestSetup> Lazy = new (() => new TestSetup()); + + private string settingFilePath = null; + + private TestSetup() + { + if (TestContext.Parameters.Count == 0) + { + this.IsDefault = true; + } + + // Read TestParameters and set runtime variables + this.PackagedContext = this.InitializeBoolParam(Constants.PackagedContextParameter, true); + this.VerboseLogging = this.InitializeBoolParam(Constants.VerboseLoggingParameter, true); + this.LooseFileRegistration = this.InitializeBoolParam(Constants.LooseFileRegistrationParameter); + this.InvokeCommandInDesktopPackage = this.InitializeBoolParam(Constants.InvokeCommandInDesktopPackageParameter); + this.SkipTestSource = this.InitializeBoolParam(Constants.SkipTestSourceParameter, this.IsDefault); + + // For packaged context, default to AppExecutionAlias + this.AICLIPath = this.InitializeStringParam(Constants.AICLIPathParameter, this.PackagedContext ? "WinGetDev.exe" : TestCommon.GetTestFile("winget.exe")); + this.AICLIPackagePath = this.InitializeStringParam(Constants.AICLIPackagePathParameter, TestCommon.GetTestFile("AppInstallerCLIPackage.appxbundle")); + + if (this.LooseFileRegistration && this.InvokeCommandInDesktopPackage) + { + this.AICLIPath = Path.Combine(this.AICLIPackagePath, this.AICLIPath); + } + + this.StaticFileRootPath = this.InitializeDirectoryParam(Constants.StaticFileRootPathParameter, Path.GetTempPath()); + + this.PowerShellModuleManifestPath = this.InitializeFileParam(Constants.PowerShellModulePathParameter); + this.LocalServerCertPath = this.InitializeFileParam(Constants.LocalServerCertPathParameter); + this.PackageCertificatePath = this.InitializeFileParam(Constants.PackageCertificatePathParameter); + this.ExeInstallerPath = this.InitializeFileParam(Constants.ExeInstallerPathParameter); + this.MsiInstallerPath = this.InitializeFileParam(Constants.MsiInstallerPathParameter); + this.MsixInstallerPath = this.InitializeFileParam(Constants.MsixInstallerPathParameter); + } + + /// <summary> + /// Gets the instance object. + /// </summary> + public static TestSetup Parameters + { + get + { + return Lazy.Value; + } + } + + /// <summary> + /// Gets the cli path. + /// </summary> + public string AICLIPath { get; } + + /// <summary> + /// Gets the package path. + /// </summary> + public string AICLIPackagePath { get; } + + /// <summary> + /// Gets a value indicating whether the test runs in package context. + /// </summary> + public bool PackagedContext { get; } + + /// <summary> + /// Gets a value indicating whether the test uses verbose logging. + /// </summary> + public bool VerboseLogging { get; } + + /// <summary> + /// Gets a value indicating whether to use loose file registration. + /// </summary> + public bool LooseFileRegistration { get; } + + /// <summary> + /// Gets a value indicating whether to invoke command in desktop package. + /// </summary> + public bool InvokeCommandInDesktopPackage { get; } + + /// <summary> + /// Gets the static file root path. + /// </summary> + public string StaticFileRootPath { get; } + + /// <summary> + /// Gets the local server cert path. + /// </summary> + public string LocalServerCertPath { get; } + + /// <summary> + /// Gets the exe installer path. + /// </summary> + public string ExeInstallerPath { get; } + + /// <summary> + /// Gets the msi installer path. + /// </summary> + public string MsiInstallerPath { get; } + + /// <summary> + /// Gets the msix installer path. + /// </summary> + public string MsixInstallerPath { get; } + + /// <summary> + /// Gets the zip installer path. + /// </summary> + public string ZipInstallerPath { get; } + + /// <summary> + /// Gets the package cert path. + /// </summary> + public string PackageCertificatePath { get; } + + /// <summary> + /// Gets the PowerShell module path. + /// </summary> + public string PowerShellModuleManifestPath { get; } + + /// <summary> + /// Gets a value indicating whether to skip creating test source. + /// </summary> + public bool SkipTestSource { get; } + + /// <summary> + /// Gets the settings json path. + /// </summary> + public string SettingsJsonFilePath + { + get + { + if (this.settingFilePath == null) + { + this.settingFilePath = WinGetSettingsHelper.GetUserSettingsPath(); + } + + return this.settingFilePath; + } + } + + /// <summary> + /// Gets a value indicating whether is the default parameters. + /// </summary> + public bool IsDefault { get; } + + private bool InitializeBoolParam(string paramName, bool defaultValue = false) + { + if (this.IsDefault || !TestContext.Parameters.Exists(paramName)) + { + return defaultValue; + } + + return TestContext.Parameters.Get(paramName).Equals("true", StringComparison.OrdinalIgnoreCase); + } + + private string InitializeStringParam(string paramName, string defaultValue = null) + { + if (this.IsDefault || !TestContext.Parameters.Exists(paramName)) + { + return defaultValue; + } + + return TestContext.Parameters.Get(paramName); + } + + private string InitializeFileParam(string paramName, string defaultValue = null) + { + if (!TestContext.Parameters.Exists(paramName)) + { + return defaultValue; + } + + var value = TestContext.Parameters.Get(paramName); + + if (!File.Exists(value)) + { + throw new FileNotFoundException($"{paramName}: {value}"); + } + + return value; + } + + private string InitializeDirectoryParam(string paramName, string defaultValue = null) + { + if (!TestContext.Parameters.Exists(paramName)) + { + return defaultValue; + } + + var value = TestContext.Parameters.Get(paramName); + + if (!Directory.Exists(value)) + { + throw new DirectoryNotFoundException($"{paramName}: {value}"); + } + + return value; + } + } +} diff --git a/src/AppInstallerCLIE2ETests/Helpers/WinGetSettingsHelper.cs b/src/AppInstallerCLIE2ETests/Helpers/WinGetSettingsHelper.cs @@ -0,0 +1,200 @@ +// ----------------------------------------------------------------------------- +// <copyright file="WinGetSettingsHelper.cs" company="Microsoft Corporation"> +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +// </copyright> +// ----------------------------------------------------------------------------- + +namespace AppInstallerCLIE2ETests.Helpers +{ + using System.Collections; + using System.IO; + using Newtonsoft.Json; + using Newtonsoft.Json.Linq; + + /// <summary> + /// Helper class to set winget settings. + /// </summary> + internal static class WinGetSettingsHelper + { + /// <summary> + /// Gets the user settings path by calling winget settings export. + /// </summary> + /// <returns>Expanded path for user settings.</returns> + public static string GetUserSettingsPath() + { + var result = TestCommon.RunAICLICommand("settings", "export"); + var output = result.StdOut; + var serialized = JObject.Parse(output); + return (string)serialized.GetValue("userSettingsFile"); + } + + /// <summary> + /// Initialize settings. + /// </summary> + public static void InitializeWingetSettings() + { + var settingsJson = new Hashtable() + { + { + "experimentalFeatures", + new Hashtable() + { + { "experimentalArg", false }, + { "experimentalCmd", false }, + { "dependencies", false }, + { "directMSI", false }, + { "download", false }, + } + }, + { + "debugging", + new Hashtable() + { + { "enableSelfInitiatedMinidump", true }, + { "keepAllLogFiles", true }, + } + }, + { + "installBehavior", + new Hashtable() + { + } + }, + }; + + // Run winget one time to initialize settings directory + // when running in unpackaged context + TestCommon.RunAICLICommand(string.Empty, "-v"); + + SetWingetSettings(settingsJson); + } + + /// <summary> + /// Converts a hashtable to json and writes to the settings file. + /// </summary> + /// <param name="settingsJson">Settings to set.</param> + public static void SetWingetSettings(Hashtable settingsJson) + { + SetWingetSettings(JsonConvert.SerializeObject(settingsJson, Formatting.Indented)); + } + + /// <summary> + /// Writes string to settings file. + /// </summary> + /// <param name="settings">Settings as string.</param> + public static void SetWingetSettings(string settings) + { + File.WriteAllText(TestSetup.Parameters.SettingsJsonFilePath, settings); + } + + /// <summary> + /// Configure experimental features. + /// </summary> + /// <param name="featureName">Feature name.</param> + /// <param name="status">Status.</param> + public static void ConfigureFeature(string featureName, bool status) + { + JObject settingsJson = JObject.Parse(File.ReadAllText(TestSetup.Parameters.SettingsJsonFilePath)); + + if (!settingsJson.ContainsKey("experimentalFeatures")) + { + settingsJson["experimentalFeatures"] = new JObject(); + } + + var experimentalFeatures = settingsJson["experimentalFeatures"]; + experimentalFeatures[featureName] = status; + + File.WriteAllText(TestSetup.Parameters.SettingsJsonFilePath, settingsJson.ToString()); + } + + /// <summary> + /// Configure the install behavior. + /// </summary> + /// <param name="settingName">Setting name.</param> + /// <param name="value">Setting value.</param> + public static void ConfigureInstallBehavior(string settingName, string value) + { + JObject settingsJson = JObject.Parse(File.ReadAllText(TestSetup.Parameters.SettingsJsonFilePath)); + + if (!settingsJson.ContainsKey("installBehavior")) + { + settingsJson["installBehavior"] = new JObject(); + } + + var installBehavior = settingsJson["installBehavior"]; + installBehavior[settingName] = value; + + File.WriteAllText(TestSetup.Parameters.SettingsJsonFilePath, settingsJson.ToString()); + } + + /// <summary> + /// Configure the install behavior preferences. + /// </summary> + /// <param name="settingName">Setting name.</param> + /// <param name="value">Setting value.</param> + public static void ConfigureInstallBehaviorPreferences(string settingName, string value) + { + JObject settingsJson = JObject.Parse(File.ReadAllText(TestSetup.Parameters.SettingsJsonFilePath)); + + if (!settingsJson.ContainsKey("installBehavior")) + { + settingsJson["installBehavior"] = new JObject(); + } + + var installBehavior = settingsJson["installBehavior"]; + + if (installBehavior["preferences"] == null) + { + installBehavior["preferences"] = new JObject(); + } + + var preferences = installBehavior["preferences"]; + preferences[settingName] = value; + + File.WriteAllText(TestSetup.Parameters.SettingsJsonFilePath, settingsJson.ToString()); + } + + /// <summary> + /// Configure the install behavior requirements. + /// </summary> + /// <param name="settingName">Setting name.</param> + /// <param name="value">Setting value.</param> + public static void ConfigureInstallBehaviorRequirements(string settingName, string value) + { + JObject settingsJson = JObject.Parse(File.ReadAllText(TestSetup.Parameters.SettingsJsonFilePath)); + + if (!settingsJson.ContainsKey("installBehavior")) + { + settingsJson["installBehavior"] = new JObject(); + } + + var installBehavior = settingsJson["installBehavior"]; + + if (installBehavior["requirements"] == null) + { + installBehavior["requirements"] = new JObject(); + } + + var requirements = installBehavior["requirements"]; + requirements[settingName] = value; + + File.WriteAllText(TestSetup.Parameters.SettingsJsonFilePath, settingsJson.ToString()); + } + + /// <summary> + /// Initialize all features. + /// </summary> + /// <param name="status">Initialized feature value.</param> + public static void InitializeAllFeatures(bool status) + { + ConfigureFeature("experimentalArg", status); + ConfigureFeature("experimentalCmd", status); + ConfigureFeature("dependencies", status); + ConfigureFeature("directMSI", status); + ConfigureFeature("pinning", status); + ConfigureFeature("configuration", status); + ConfigureFeature("windowsFeature", status); + ConfigureFeature("download", status); + } + } +} diff --git a/src/AppInstallerCLIE2ETests/ImportCommand.cs b/src/AppInstallerCLIE2ETests/ImportCommand.cs @@ -6,7 +6,8 @@ namespace AppInstallerCLIE2ETests { - using System.IO; + using System.IO; + using AppInstallerCLIE2ETests.Helpers; using NUnit.Framework; /// <summary> diff --git a/src/AppInstallerCLIE2ETests/InstallCommand.cs b/src/AppInstallerCLIE2ETests/InstallCommand.cs @@ -7,6 +7,7 @@ namespace AppInstallerCLIE2ETests { using System.IO; + using AppInstallerCLIE2ETests.Helpers; using NUnit.Framework; /// <summary> @@ -698,7 +699,7 @@ namespace AppInstallerCLIE2ETests Assert.AreEqual(Constants.ErrorCode.ERROR_NO_APPLICATIONS_FOUND, result.ExitCode); // Add the MSIX to simulate an installer doing it - TestCommon.InstallMsix(TestCommon.MsixInstallerPath); + TestCommon.InstallMsix(TestIndex.MsixInstaller); // Install our exe that "installs" the MSIX result = TestCommon.RunAICLICommand("install", $"{targetPackageIdentifier} --force"); diff --git a/src/AppInstallerCLIE2ETests/Interop/CheckInstalledStatusInterop.cs b/src/AppInstallerCLIE2ETests/Interop/CheckInstalledStatusInterop.cs @@ -8,7 +8,8 @@ namespace AppInstallerCLIE2ETests.Interop { using System; using System.IO; - using System.Threading.Tasks; + using System.Threading.Tasks; + using AppInstallerCLIE2ETests.Helpers; using Microsoft.Management.Deployment; using Microsoft.Management.Deployment.Projection; using NUnit.Framework; diff --git a/src/AppInstallerCLIE2ETests/Interop/DownloadInterop.cs b/src/AppInstallerCLIE2ETests/Interop/DownloadInterop.cs @@ -8,7 +8,8 @@ namespace AppInstallerCLIE2ETests.Interop { using System; using System.IO; - using System.Threading.Tasks; + using System.Threading.Tasks; + using AppInstallerCLIE2ETests.Helpers; using Microsoft.Management.Deployment; using Microsoft.Management.Deployment.Projection; using NUnit.Framework; diff --git a/src/AppInstallerCLIE2ETests/Interop/InstallInterop.cs b/src/AppInstallerCLIE2ETests/Interop/InstallInterop.cs @@ -9,6 +9,7 @@ namespace AppInstallerCLIE2ETests.Interop using System; using System.IO; using System.Threading.Tasks; + using AppInstallerCLIE2ETests.Helpers; using Microsoft.Management.Deployment; using Microsoft.Management.Deployment.Projection; using NUnit.Framework; @@ -190,7 +191,7 @@ namespace AppInstallerCLIE2ETests.Interop [Test] public async Task InstallMSI() { - if (string.IsNullOrEmpty(TestCommon.MsiInstallerPath)) + if (string.IsNullOrEmpty(TestIndex.MsiInstaller)) { Assert.Ignore("MSI installer not available"); } diff --git a/src/AppInstallerCLIE2ETests/Interop/InteropSetUpFixture.cs b/src/AppInstallerCLIE2ETests/Interop/InteropSetUpFixture.cs @@ -6,7 +6,8 @@ namespace AppInstallerCLIE2ETests.Interop { - using System; + using System; + using AppInstallerCLIE2ETests.Helpers; using NUnit.Framework; /// <summary> diff --git a/src/AppInstallerCLIE2ETests/Interop/UninstallInterop.cs b/src/AppInstallerCLIE2ETests/Interop/UninstallInterop.cs @@ -1,20 +1,21 @@ -// ----------------------------------------------------------------------------- -// <copyright file="UninstallInterop.cs" company="Microsoft Corporation"> -// Copyright (c) Microsoft Corporation. Licensed under the MIT License. -// </copyright> -// ----------------------------------------------------------------------------- - +// ----------------------------------------------------------------------------- +// <copyright file="UninstallInterop.cs" company="Microsoft Corporation"> +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +// </copyright> +// ----------------------------------------------------------------------------- + namespace AppInstallerCLIE2ETests.Interop { using System; using System.IO; using System.Threading.Tasks; + using AppInstallerCLIE2ETests.Helpers; using Microsoft.Management.Deployment; using Microsoft.Management.Deployment.Projection; using NUnit.Framework; - /// <summary> - /// Test uninstall interop. + /// <summary> + /// Test uninstall interop. /// </summary> [TestFixtureSource(typeof(InstanceInitializersSource), nameof(InstanceInitializersSource.InProcess), Category = nameof(InstanceInitializersSource.InProcess))] [TestFixtureSource(typeof(InstanceInitializersSource), nameof(InstanceInitializersSource.OutOfProcess), Category = nameof(InstanceInitializersSource.OutOfProcess))] @@ -22,19 +23,19 @@ namespace AppInstallerCLIE2ETests.Interop { private string installDir; private PackageManager packageManager; - private PackageCatalogReference compositeSource; - - /// <summary> - /// Initializes a new instance of the <see cref="UninstallInterop"/> class. - /// </summary> - /// <param name="initializer">Initializer.</param> + private PackageCatalogReference compositeSource; + + /// <summary> + /// Initializes a new instance of the <see cref="UninstallInterop"/> class. + /// </summary> + /// <param name="initializer">Initializer.</param> public UninstallInterop(IInstanceInitializer initializer) : base(initializer) { } - /// <summary> - /// Set up. + /// <summary> + /// Set up. /// </summary> [SetUp] public void Init() @@ -49,11 +50,11 @@ namespace AppInstallerCLIE2ETests.Interop options.Catalogs.Add(testSource); options.CompositeSearchBehavior = CompositeSearchBehavior.AllCatalogs; this.compositeSource = this.packageManager.CreateCompositePackageCatalog(options); - } - - /// <summary> - /// Test uninstall exe. - /// </summary> + } + + /// <summary> + /// Test uninstall exe. + /// </summary> /// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns> [Test] public async Task UninstallTestExe() @@ -80,14 +81,14 @@ namespace AppInstallerCLIE2ETests.Interop Assert.True(TestCommon.VerifyTestExeUninstalled(this.installDir)); } - /// <summary> - /// Test uninstall msi. - /// </summary> + /// <summary> + /// Test uninstall msi. + /// </summary> /// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns> [Test] public async Task UninstallTestMsi() { - if (string.IsNullOrEmpty(TestCommon.MsiInstallerPath)) + if (string.IsNullOrEmpty(TestIndex.MsiInstaller)) { Assert.Ignore("MSI installer not available"); } @@ -113,9 +114,9 @@ namespace AppInstallerCLIE2ETests.Interop Assert.True(TestCommon.VerifyTestMsiUninstalled(this.installDir)); } - /// <summary> - /// Test uninstall msix. - /// </summary> + /// <summary> + /// Test uninstall msix. + /// </summary> /// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns> [Test] public async Task UninstallTestMsix() @@ -140,14 +141,14 @@ namespace AppInstallerCLIE2ETests.Interop Assert.True(TestCommon.VerifyTestMsixUninstalled()); } - /// <summary> - /// Test uninstall msix with machine scope. - /// </summary> + /// <summary> + /// Test uninstall msix with machine scope. + /// </summary> /// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns> [Test] public async Task UninstallTestMsixMachineScope() { - // TODO: Provision and Deprovision api not supported in build server. + // TODO: Provision and Deprovision api not supported in build server. Assert.Ignore(); // Find package @@ -174,9 +175,9 @@ namespace AppInstallerCLIE2ETests.Interop Assert.True(TestCommon.VerifyTestMsixUninstalled(true)); } - /// <summary> - /// Test uninstall portable package. - /// </summary> + /// <summary> + /// Test uninstall portable package. + /// </summary> /// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns> [Test] public async Task UninstallPortable() @@ -207,9 +208,9 @@ namespace AppInstallerCLIE2ETests.Interop TestCommon.VerifyPortablePackage(Path.Combine(installDir, packageDirName), commandAlias, fileName, productCode, false); } - /// <summary> - /// Test uninstall portable package with product code. - /// </summary> + /// <summary> + /// Test uninstall portable package with product code. + /// </summary> /// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns> [Test] public async Task UninstallPortableWithProductCode() @@ -240,9 +241,9 @@ namespace AppInstallerCLIE2ETests.Interop TestCommon.VerifyPortablePackage(Path.Combine(installDir, packageDirName), commandAlias, fileName, productCode, false); } - /// <summary> - /// Test uninstall portable package modified symlink. - /// </summary> + /// <summary> + /// Test uninstall portable package modified symlink. + /// </summary> /// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns> [Test] public async Task UninstallPortableModifiedSymlink() @@ -279,9 +280,9 @@ namespace AppInstallerCLIE2ETests.Interop modifiedSymlinkInfo.Delete(); } - /// <summary> - /// Test uninstall not indexed. - /// </summary> + /// <summary> + /// Test uninstall not indexed. + /// </summary> /// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns> [Test] public async Task UninstallNotIndexed() diff --git a/src/AppInstallerCLIE2ETests/Interop/UpgradeInterop.cs b/src/AppInstallerCLIE2ETests/Interop/UpgradeInterop.cs @@ -9,7 +9,8 @@ namespace AppInstallerCLIE2ETests.Interop using System; using System.Collections.Generic; using System.IO; - using System.Threading.Tasks; + using System.Threading.Tasks; + using AppInstallerCLIE2ETests.Helpers; using Microsoft.Management.Deployment; using Microsoft.Management.Deployment.Projection; using NUnit.Framework; diff --git a/src/AppInstallerCLIE2ETests/ListCommand.cs b/src/AppInstallerCLIE2ETests/ListCommand.cs @@ -7,6 +7,7 @@ namespace AppInstallerCLIE2ETests { using System.IO; + using AppInstallerCLIE2ETests.Helpers; using NUnit.Framework; /// <summary> @@ -84,7 +85,7 @@ namespace AppInstallerCLIE2ETests // Installs the MSI installer using the TestMsiInstaller package. // Then tries listing the TestMsiInstallerUpgradeCode package, which should // be correlated to it by the UpgradeCode. - if (string.IsNullOrEmpty(TestCommon.MsiInstallerPath)) + if (string.IsNullOrEmpty(TestIndex.MsiInstaller)) { Assert.Ignore("MSI installer not available"); } diff --git a/src/AppInstallerCLIE2ETests/Pinning.cs b/src/AppInstallerCLIE2ETests/Pinning.cs @@ -7,8 +7,9 @@ namespace AppInstallerCLIE2ETests { using System.IO; + using AppInstallerCLIE2ETests.Helpers; using NUnit.Framework; - using static AppInstallerCLIE2ETests.TestCommon; + using static AppInstallerCLIE2ETests.Helpers.TestCommon; /// <summary> /// Test upgrading pinned packages. diff --git a/src/AppInstallerCLIE2ETests/PowerShell/PowerShellHost.cs b/src/AppInstallerCLIE2ETests/PowerShell/PowerShellHost.cs @@ -10,6 +10,7 @@ namespace AppInstallerCLIE2ETests.PowerShell using System.Collections; using System.Management.Automation; using System.Management.Automation.Runspaces; + using AppInstallerCLIE2ETests.Helpers; using Microsoft.PowerShell; using NUnit.Framework; @@ -31,7 +32,7 @@ namespace AppInstallerCLIE2ETests.PowerShell initialSessionState.ExecutionPolicy = ExecutionPolicy.Unrestricted; initialSessionState.ImportPSModule(new string[] { - TestCommon.PowerShellModulePath, + TestSetup.Parameters.PowerShellModuleManifestPath, }); this.runspace = RunspaceFactory.CreateRunspace(initialSessionState); diff --git a/src/AppInstallerCLIE2ETests/PowerShell/WinGetClientModule.cs b/src/AppInstallerCLIE2ETests/PowerShell/WinGetClientModule.cs @@ -11,6 +11,7 @@ namespace AppInstallerCLIE2ETests.PowerShell using System.Diagnostics; using System.Linq; using System.Management.Automation; + using AppInstallerCLIE2ETests.Helpers; using NUnit.Framework; /// <summary> diff --git a/src/AppInstallerCLIE2ETests/README.md b/src/AppInstallerCLIE2ETests/README.md @@ -1,50 +1,13 @@ # How to Run End-To-End Tests for Windows Package Manager Client +Most of the tests require having the local test source added into winget. The local test source must be hosted in a localhost web server. -## Step 1: Launch Localhost Web Server -In order to run any of the E2E tests, you will need to first launch the LocalhostWebServer executable. This program serves static test files from a given directory path through a HTTPS local loopback server in order to maintain a closed and controlled repository for resources used for testing purposes. - -### Parameters - -The executable has 3 mandatory parameters and 1 optional parameter: -|Parameter Name | Mandatory/Optional | Description | -|--|--|--| -| **StaticFileRoot** | Mandatory | Path to serve static root directory. If the directory path does not exist, a new directory will be created for you. | -| **CertPath** | Mandatory | Path to HTTPS Developer Certificate. A self signed developer certificate will need to be created in order to verify localhost https. | -| **CertPassword** | Mandatory | HTTPS Developer Certificate Password | -| **Port** | Optional | Port number [Default Port Number: 5001] | - -### How to create and trust an ASP.NET Core HTTPS Development Certificate -Windows Package Manager Client (WinGet.exe) requires new sources added to the WinGet repositories be securely accessed through HTTPS. Therefore, in order to verify the LocalhostWebServer, you will need to create a self-signed development certificate to verify the localhost address. - -- Open command prompt in administrator mode -- Run **dotnet dev-certs https --trust** in the command line -- Open up **certmgr** (search Manage User Certificates in Windows search bar) -- Locate the newly created localhost certificate in the Personal/Certificates folder with a friendly name of "ASP.NET Core HTTPS development certificate" -- Right click on the certificate --> All Tasks --> Export.. -- Click Yes to export the private key -- Export file using Personal Information Exchange (.pfx) file format -- Create and confirm password using SHA256 encryption (any password will work, just make sure to remember it for later) -- Save HTTPS development certificate and refer its certificate path and password when launching the Localhost Webserver -### How to run LocalhostWebServer.exe -The executable can most likely be found in this path: **\src\x86\Release\LocalhostWebServer** - -The command line call to run the executable needs to follow the format: - - LocalhostWebServer.exe StaticFileRoot=<Path to Serve Static Root Directory> CertPath=<Path to HTTPS Developer Certificate> CertPassword=<Certificate Password> <Port=Port Number> - -Therefore to run the executable in the command line, simply change into the directory that contains **LocalhostWebServer.exe** and run the executable with the corresponding parameter values. Here is an example: (Don't forget to modify the path to match your own local computer) - - cd C:\Users\MSFT\source\repos\winget-cli\src\AnyCPU\Debug\LocalhostWebServer - - LocalhostWebServer.exe StaticFileRoot=C:\Users\MSFT\AppData\Local\Temp\TestLocalIndex CertPath=C:\Users\MSFT\Temp\HTTPSDevCert.pfx CertPassword=password - - -## 2. Prepare Test.runsettings file - The E2E tests are built on the nunit testing framework and rely on this test.runsettings file located here: **D:\Src\WinGet\Client\src\AppInstallerCLIE2ETests\Test.runsettings**. These parameters are used by the tests at runtime and need to be configured before running any of the E2E tests. - After populating the parameters in the Test.runsettings file, make sure to configure the run settings to point to the file. This can be done by opening **Test Explorer - Settings - Configure Run Settings point to file: D:\Src\WinGet\Client\src\AppInstallerCLIE2ETests\Test.runsettings** +## Run locally +The E2E tests are built on the nunit testing framework and can be configured with a Test.runsettings file. The project has its own default parameters but typically you will want to expand it by modifying **src\AppInstallerCLIE2ETests\Test.runsettings** with the parameters that you want and set it up by opening **Test Explorer - Settings - Configure Run Settings point to file: src\AppInstallerCLIE2ETests\Test.runsettings** +If your tests uses the test source see the [LocalhostWebServer](#LocalhostWebServer) and [WinGetSourceCreator](#WinGetSourceCreator) sections. +### Run settings. |Parameter| Description | |--|--| | PackagedContext | Indicates if the test should be run under packaged context | @@ -56,46 +19,31 @@ Therefore to run the executable in the command line, simply change into the dire | StaticFileRootPath | Path to the set of static test files that will be served as the source for testing purposes. This path should be identical to the one provided to the LocalHostWebServer| | MsixTestInstallerPath | The MSIX (or APPX) Installer executable under test. | | ExeTestInstallerPath |The Exe Installer executable under test. | +| MsiTestInstallerPath | The MSI Installer executable under test. | | PackageCertificatePath | Signing Certificate Path used to certify test index source package | | PowerShellModulePath | Path to the PowerShell module manifest file under test | +| PowerShellModulePath | The local server cert file | +| SkipTestSource | I solemnly swear the test won't use the local test source or the source is already set up. | -#### Example of Test.runsettings format: - - <RunSettings> - <TestRunParameters> - <Parameter name="PackagedContext" value="true" /> - <Parameter name="VerboseLogging" value="false" /> - <Parameter name="AICLIPath" value="AppInst.exe" /> - <Parameter name="AICLIPackagePath" value="AppInstallerCLIPackage.appxbundle" /> - <Parameter name="LooseFileRegistration" value="false" /> - <Parameter name="InvokeCommandInDesktopPackage" value="false" /> - <Parameter name="StaticFileRootPath" value="\TestLocalIndex" /> - <Parameter name="MsixTestInstallerPath" value="MsixTestInstaller.msix" /> - <Parameter name="ExeTestInstallerPath" value="ExeTestInstaller.exe" /> - <Parameter name="PackageCertificatePath" value="certificate.pfx"/> - <Parameter name="PowerShellModulePath" value="TestPowerShellModule.psd1" /> - </TestRunParameters> - </RunSettings> - #### Example of Test.runsettings with completed parameters: -Make sure to replace **MSFT** with your own user name. Modifying this example with the correct path to each test run parameter for your own local computer should be sufficient to successfully run the E2E tests once all steps are completed. - - <RunSettings> - <TestRunParameters> - <Parameter name="PackagedContext" value="false" /> - <Parameter name="VerboseLogging" value="false" /> - <Parameter name="AICLIPath" value="C:\Users\<user>\source\repos\winget-cli\src\x64\Debug\AppInstallerCLI\AppInstallerCLI.exe" /> - <Parameter name="AICLIPackagePath" value="AppInstallerCLIPackage.appxbundle" /> - <Parameter name="LooseFileRegistration" value="false" /> - <Parameter name="InvokeCommandInDesktopPackage" value="false" /> - <Parameter name="StaticFileRootPath" value="C:\Users\MSFT\AppData\Local\Temp\TestLocalIndex" /> - <Parameter name="MsixTestInstallerPath" value="C:\Users\MSFT\Temp\MsixTestInstaller.msix" /> - <Parameter name="ExeTestInstallerPath" value="C:\Users\MSFT\source\repos\winget-cli\src\x64\Debug\AppInstallerTestExeInstaller\AppInstallerTestExeInstaller.exe" /> - <Parameter name="PackageCertificatePath" value="C:\Users\MSFT\Temp\packageCertificate.pfx"/> - <Parameter name="PowerShellModulePath" value="C:\Users\MSFT\source\repos\winget-cli\src\x64\Debug\PowerShell\Microsoft.WinGet.Client.psd1" /> - </TestRunParameters> - </RunSettings> - +Assuming you clone winget-cli in c:\dev, the localhost web server is running in c:\dev\TestLocalIndex (without creating the test source) and you built x64 Debug, this should cover most of the tests. + + <RunSettings> + <TestRunParameters> + <Parameter name="PackagedContext" value="false" /> + <Parameter name="VerboseLogging" value="true" /> + <Parameter name="AICLIPath" value="c:\dev\winget-cli\src\AppInstallerCLIPackage\bin\x64\Debug\AppInstallerCLI\winget.exe" /> + <Parameter name="InvokeCommandInDesktopPackage" value="false" /> + <Parameter name="StaticFileRootPath" value="c:\dev\TestLocalIndex" /> + <Parameter name="MsixTestInstallerPath" value="C:\dev\Temp\AppInstallerTestMsixInstaller.msix" /> + <Parameter name="ExeTestInstallerPath" value="c:\dev\winget-cli\src\x64\Debug\AppInstallerTestExeInstaller\AppInstallerTestExeInstaller.exe" /> + <Parameter name="MsiTestInstallerPath" value="c:\dev\winget-cli\src\AppInstallerCLIE2ETests\TestData\AppInstallerTestMsiInstaller.msi" /> + <Parameter name="PackageCertificatePath" value="C:\dev\Temp\packageCertificate.pfx"/> + <Parameter name="PowerShellModulePath" value="c:\dev\winget-cli\src\x64\Debug\PowerShell\Microsoft.WinGet.Client.psd1" /> + </TestRunParameters> + </RunSettings> + +The easiest way to generate AppInstallerTestMsixInstaller.msix is by running makeappx pack for c:\dev\winget-cli\src\AppInstallerTestMsixInstaller\bin\x64\Debug. #### Log Files After running the E2E Tests, the logs can be found in the following paths: @@ -103,3 +51,124 @@ After running the E2E Tests, the logs can be found in the following paths: - **%LOCALAPPDATA%\Packages\WinGetDevCLI_8wekyb3d8bbwe\LocalState\DiagOutputDir** - **%LOCALAPPDATA%\E2ETestLogs** - **%TEMP%\WinGet\defaultState** + +## LocalhostWebServer +The src\Tool\LocalhostWebServer project generates an executable that serves static test files from a given directory path through a HTTPS local loopback server in order to maintain a closed and controlled repository for resources used for testing purposes. + +### Start localhost web server + +The localhost web server needs to be running for the duration of the tests. The easiest way to run it is to use src\Tool\LocalhostWebServer\Run-LocalhostWebServer.ps1 in a different PowerShell session. + +|Parameter | Type | Description | +|--|--|--| +| **BuildRoot** | Mandatory | The output path of the LocalhostWebServer project. Normally something like <winget repo root>\src\<arch>\<configuration>\LocalhostWebServer +| **StaticFileRoot** | Mandatory | Path to serve static root directory. If the directory path does not exist, a new directory will be created for you. | +| **CertPath** | Mandatory | Path to HTTPS Developer Certificate. A self signed developer certificate will need to be created in order to verify localhost https. | +| **CertPassword** | Mandatory | HTTPS Developer Certificate Password | +| **Port** | Optional | Port number [Default Port Number: 5001] | +| **OutCertFile** | Optional | The exported certificate used | +| **LocalSourceJson** | Optional | The local source definition. If set generates the source. | + +### How to create and trust an ASP.NET Core HTTPS Development Certificate +Windows Package Manager Client (WinGet.exe) requires new sources added to the WinGet repositories be securely accessed through HTTPS. Therefore, in order to verify the LocalhostWebServer, you will need to create a self-signed development certificate to verify the localhost address. + +- Open command prompt in administrator mode +- Run **dotnet dev-certs https --trust** in the command line +- Open up **certmgr** (search Manage User Certificates in Windows search bar) +- Locate the newly created localhost certificate in the Personal/Certificates folder with a friendly name of "ASP.NET Core HTTPS development certificate" +- Right click on the certificate --> All Tasks --> Export.. +- Click Yes to export the private key +- Export file using Personal Information Exchange (.pfx) file format +- Create and confirm password using SHA256 encryption (any password will work, just make sure to remember it for later) +- Save HTTPS development certificate and refer its certificate path and password when launching the Localhost Webserver + +## WinGetSourceCreator +The src\WinGetSourceCreator is a project that helps generate a new winget source for local development. It is consumed by IndexCreationTool, LocalhostWebServer and AppInstallerCLIE2ETests projects. It supports: +- Prepare installers by signing them and placing then in the working directory and computing their hashes. For msix, signature hash is also supported. +- Generate zip installers. +- Generate the signed source.msix and index.db. + +LocalSource is the object that contains the definition of the source. A json serialized version of it is the input for IndexCreationTool and LocalhostWebServer. + +Example: +``` +{ + # If running E2E this is must be the StaticFileRoot used for the localhost web server + "WorkingDirectory": "c:/dev/temp/TestLocalIndex", + + # The appx manifest to generate the source.msix file. + "AppxManifest": "c:/dev/winget-cli/src/AppInstallerCLIE2ETests/TestData/Package/AppxManifest.xml", + + # A list of directories or files to copy. If a directory, it copies all the *.yaml files preserving subdirectories. + "LocalManifests": [ + "c:/dev/winget-cli/src/x86/Release/AppInstallerCLIE2ETests/TestData/Manifests" + ], + + # The signature to use. + "Signature": { + "CertFile": "cert.pfx", + "Password": "1324", + + # If set it will modify the Package Identity Publisher in the AppxManifest.xml + "Publisher": "CN:ThousandSunny" + } + + # Installers that are already present in the machine, by default the installers will be signed using their Signature + # property if set or the top level one. + "LocalInstallers": [ + { + "Type": "exe", + "Input": "c:/dev/winget-cli/src/x64/Debug/AppInstallerTestExeInstaller\AppInstallerTestExeInstaller.exe", + + # Name of the installer to be copied and signed if needed. + "Name": "AppInstallerTestExeInstaller/AppInstallerTestExeInstaller.exe", + + # The token in the manifests for this installer. This will be replaces at copy manifests time. + "HashToken": "<EXEHASH>" + + # Overrides top level one. + "Signature": { + "CertFile": "cert2.pfx", + "Password": "2345", + } + }, + { + "Type": "msi", + "Input": "c:/dev/winget-cli/src/AppInstallerCLIE2ETests/TestData/AppInstallerTestMsiInstaller.msi", + "Name": "AppInstallerTestMsiInstaller/AppInstallerTestMsiInstaller.msi", + "HashToken": "<MSIHASH>" + + # Don't sign this. + "SkipSignature": true + }, + { + "Type": "msix", + "Input": "D:/dev/temp/AppInstallerTestMsixInstaller.msix", + "Name": "AppInstallerTestMsixInstaller/AppInstallerTestMsixInstaller.msix", + "HashToken": "<MSIXHASH>", + + # Only supported by where type is msix. Package must be signed, either already signed or signed when copied. + "SignatureToken": "<SIGNATUREHASH>", + } + ], + + # These are installers that are generated on the go. Currently only zip is supported. + "DynamicInstallers": [ + { + # Zip installers are never signed. + "Type": "zip", + + # List of files to zip. Does not preserve subdirectories. + "Input": [ + "D:/dev/temp/TestLocalIndex/AppInstallerTestExeInstaller/AppInstallerTestExeInstaller.exe", + "D:/dev/temp/TestLocalIndex/AppInstallerTestMsiInstaller/AppInstallerTestMsiInstaller.msi", + "D:/dev/temp/TestLocalIndex/AppInstallerTestMsixInstaller/AppInstallerTestMsixInstaller.msix" + ], + + "Name": "AppInstallerTestZipInstaller/AppInstallerTestZipInstaller.zip", + + "HashToken": "<ZIPHASH>" + } + ], +} +``` diff --git a/src/AppInstallerCLIE2ETests/RunCommandException.cs b/src/AppInstallerCLIE2ETests/RunCommandException.cs @@ -7,7 +7,7 @@ namespace AppInstallerCLIE2ETests { using System; - using static AppInstallerCLIE2ETests.TestCommon; + using static AppInstallerCLIE2ETests.Helpers.TestCommon; /// <summary> /// An exception that occurred when running a command. diff --git a/src/AppInstallerCLIE2ETests/SearchCommand.cs b/src/AppInstallerCLIE2ETests/SearchCommand.cs @@ -6,6 +6,7 @@ namespace AppInstallerCLIE2ETests { + using AppInstallerCLIE2ETests.Helpers; using NUnit.Framework; /// <summary> diff --git a/src/AppInstallerCLIE2ETests/SetUpFixture.cs b/src/AppInstallerCLIE2ETests/SetUpFixture.cs @@ -6,8 +6,7 @@ namespace AppInstallerCLIE2ETests { - using System; - using System.IO; + using AppInstallerCLIE2ETests.Helpers; using Microsoft.Win32; using NUnit.Framework; @@ -28,107 +27,39 @@ namespace AppInstallerCLIE2ETests [OneTimeSetUp] public void Setup() { - if (TestContext.Parameters.Count == 0) + var testParams = TestSetup.Parameters; + + if (testParams.IsDefault) { // If no parameters are provided, use defaults that work locally. // This allows the user to assume responsibility for setup. - TestCommon.PackagedContext = true; - TestCommon.VerboseLogging = true; - TestCommon.AICLIPath = "WinGetDev.exe"; - TestCommon.StaticFileRootPath = Path.GetTempPath(); - TestCommon.SettingsJsonFilePath = WinGetSettingsHelper.GetUserSettingsPath(); - WinGetSettingsHelper.InitializeWingetSettings(); shouldDoAnyTeardown = false; - - return; - } - - // Read TestParameters and set runtime variables - TestCommon.PackagedContext = TestContext.Parameters.Exists(Constants.PackagedContextParameter) && - TestContext.Parameters.Get(Constants.PackagedContextParameter).Equals("true", StringComparison.OrdinalIgnoreCase); - - TestCommon.VerboseLogging = TestContext.Parameters.Exists(Constants.VerboseLoggingParameter) && - TestContext.Parameters.Get(Constants.VerboseLoggingParameter).Equals("true", StringComparison.OrdinalIgnoreCase); - - TestCommon.LooseFileRegistration = TestContext.Parameters.Exists(Constants.LooseFileRegistrationParameter) && - TestContext.Parameters.Get(Constants.LooseFileRegistrationParameter).Equals("true", StringComparison.OrdinalIgnoreCase); - - TestCommon.InvokeCommandInDesktopPackage = TestContext.Parameters.Exists(Constants.InvokeCommandInDesktopPackageParameter) && - TestContext.Parameters.Get(Constants.InvokeCommandInDesktopPackageParameter).Equals("true", StringComparison.OrdinalIgnoreCase); - - if (TestContext.Parameters.Exists(Constants.AICLIPathParameter)) - { - TestCommon.AICLIPath = TestContext.Parameters.Get(Constants.AICLIPathParameter); - } - else - { - if (TestCommon.PackagedContext) - { - // For packaged context, default to AppExecutionAlias - TestCommon.AICLIPath = "WinGetDev.exe"; - } - else - { - TestCommon.AICLIPath = TestCommon.GetTestFile("winget.exe"); - } - } - - if (TestContext.Parameters.Exists(Constants.AICLIPackagePathParameter)) - { - TestCommon.AICLIPackagePath = TestContext.Parameters.Get(Constants.AICLIPackagePathParameter); } else { - TestCommon.AICLIPackagePath = TestCommon.GetTestFile("AppInstallerCLIPackage.appxbundle"); - } - - if (TestCommon.LooseFileRegistration && TestCommon.InvokeCommandInDesktopPackage) - { - TestCommon.AICLIPath = Path.Combine(TestCommon.AICLIPackagePath, TestCommon.AICLIPath); - } - - shouldDisableDevModeOnExit = this.EnableDevMode(true); + shouldDisableDevModeOnExit = this.EnableDevMode(true); - shouldRevertDefaultFileTypeRiskOnExit = this.DecreaseFileTypeRisk(".exe;.msi", false); + shouldRevertDefaultFileTypeRiskOnExit = this.DecreaseFileTypeRisk(".exe;.msi", false); - Assert.True(TestCommon.RunCommand("certutil.exe", "-addstore -f \"TRUSTEDPEOPLE\" " + TestCommon.GetTestDataFile(Constants.AppInstallerTestCert)), "Add AppInstallerTestCert"); + Assert.True(TestCommon.RunCommand("certutil.exe", "-addstore -f \"TRUSTEDPEOPLE\" " + TestCommon.GetTestDataFile(Constants.AppInstallerTestCert)), "Add AppInstallerTestCert"); - if (TestCommon.PackagedContext) - { - if (TestCommon.LooseFileRegistration) + if (testParams.PackagedContext) { - Assert.True(TestCommon.InstallMsixRegister(TestCommon.AICLIPackagePath), $"InstallMsixRegister : {TestCommon.AICLIPackagePath}"); - } - else - { - Assert.True(TestCommon.InstallMsix(TestCommon.AICLIPackagePath), $"InstallMsix : {TestCommon.AICLIPackagePath}"); + if (testParams.LooseFileRegistration) + { + Assert.True(TestCommon.InstallMsixRegister(testParams.AICLIPackagePath), $"InstallMsixRegister : {testParams.AICLIPackagePath}"); + } + else + { + Assert.True(TestCommon.InstallMsix(testParams.AICLIPackagePath), $"InstallMsix : {testParams.AICLIPackagePath}"); + } } } - if (TestContext.Parameters.Exists(Constants.StaticFileRootPathParameter)) + if (!testParams.SkipTestSource) { - TestCommon.StaticFileRootPath = TestContext.Parameters.Get(Constants.StaticFileRootPathParameter); + TestIndex.GenerateE2ESource(); } - else - { - TestCommon.StaticFileRootPath = Path.GetTempPath(); - } - - if (TestContext.Parameters.Exists(Constants.PackageCertificatePathParameter)) - { - TestCommon.PackageCertificatePath = TestContext.Parameters.Get(Constants.PackageCertificatePathParameter); - } - - if (TestContext.Parameters.Exists(Constants.PowerShellModulePathParameter)) - { - TestCommon.PowerShellModulePath = TestContext.Parameters.Get(Constants.PowerShellModulePathParameter); - } - - this.ReadTestInstallerPaths(); - - TestIndexSetup.GenerateTestDirectory(); - - TestCommon.SettingsJsonFilePath = WinGetSettingsHelper.GetUserSettingsPath(); WinGetSettingsHelper.InitializeWingetSettings(); } @@ -155,7 +86,7 @@ namespace AppInstallerCLIE2ETests TestCommon.PublishE2ETestLogs(); - if (TestCommon.PackagedContext) + if (TestSetup.Parameters.PackagedContext) { TestCommon.RemoveMsix(Constants.AICLIPackageName); } @@ -215,26 +146,5 @@ namespace AppInstallerCLIE2ETests return true; } } - - private void ReadTestInstallerPaths() - { - if (TestContext.Parameters.Exists(Constants.ExeInstallerPathParameter) - && File.Exists(TestContext.Parameters.Get(Constants.ExeInstallerPathParameter))) - { - TestCommon.ExeInstallerPath = TestContext.Parameters.Get(Constants.ExeInstallerPathParameter); - } - - if (TestContext.Parameters.Exists(Constants.MsiInstallerPathParameter) - && File.Exists(TestContext.Parameters.Get(Constants.MsiInstallerPathParameter))) - { - TestCommon.MsiInstallerPath = TestContext.Parameters.Get(Constants.MsiInstallerPathParameter); - } - - if (TestContext.Parameters.Exists(Constants.MsixInstallerPathParameter) - && File.Exists(TestContext.Parameters.Get(Constants.MsixInstallerPathParameter))) - { - TestCommon.MsixInstallerPath = TestContext.Parameters.Get(Constants.MsixInstallerPathParameter); - } - } } } diff --git a/src/AppInstallerCLIE2ETests/ShowCommand.cs b/src/AppInstallerCLIE2ETests/ShowCommand.cs @@ -6,6 +6,7 @@ namespace AppInstallerCLIE2ETests { + using AppInstallerCLIE2ETests.Helpers; using NUnit.Framework; /// <summary> diff --git a/src/AppInstallerCLIE2ETests/SourceCommand.cs b/src/AppInstallerCLIE2ETests/SourceCommand.cs @@ -6,6 +6,7 @@ namespace AppInstallerCLIE2ETests { + using AppInstallerCLIE2ETests.Helpers; using NUnit.Framework; /// <summary> diff --git a/src/AppInstallerCLIE2ETests/Test.runsettings b/src/AppInstallerCLIE2ETests/Test.runsettings @@ -4,35 +4,39 @@ <!-- Parameters used by tests at run time. - PackagedContext: Indicates if the test should be run under packaged context - VerboseLogging: Turn on/off verbose logging in the test result - AICLIPath: The AICLI executable under test. - If using loose file registration and using Invoke-Command when - AppExecutionAlias is not available, this will be relative path to package root. - AICLIPackagePath: Used in packaged context. Path to the package containing executable under test. - If LooseFileRegistration is true, this should be path to unpackaged root. - LooseFileRegistration: Bool to set if loose file registration should be used. - InvokeCommandInDesktopPackage: Bool to indicate using Invoke-CommandInDesktopPackage for test execution. - This is used when AppExecutionAlias is not available, or disabled. - StaticFileRootPath: Path to the set of static test files that will be served as the source for testing purposes. - MsiTestInstallerPath: The Msi Installer executable under test. - MsixTestInstallerPath: The MSIX (or APPX) Installer executable under test. - ExeTestInstallerPath: The Exe Installer executable under test. - PackageCertificatePath: Signing Certificate Path used to certify Index Source Package - PowerShellModulePath: Path to the PowerShell module manifest file under test + PackagedContext: Indicates if the test should be run under packaged context + VerboseLogging: Turn on/off verbose logging in the test result + AICLIPath: The AICLI executable under test. + If using loose file registration and using Invoke-Command when + AppExecutionAlias is not available, this will be relative path to package root. + AICLIPackagePath: Used in packaged context. Path to the package containing executable under test. + If LooseFileRegistration is true, this should be path to unpackaged root. + LooseFileRegistration: Bool to set if loose file registration should be used. + InvokeCommandInDesktopPackage: Bool to indicate using Invoke-CommandInDesktopPackage for test execution. + This is used when AppExecutionAlias is not available, or disabled. + StaticFileRootPath: Path to the set of static test files that will be served as the source for testing purposes. + MsiTestInstallerPath: The Msi Installer executable under test. + MsixTestInstallerPath: The MSIX (or APPX) Installer executable under test. + ExeTestInstallerPath: The Exe Installer executable under test. + PackageCertificatePath: Signing Certificate Path used to certify Index Source Package. + PowerShellModulePath: Path to the PowerShell module manifest file under test. + LocalServerCertPathParameter: The cert used for the local server. + SkipTestSource: I solemnly swear the test won't use the local test source or the source is already set up. --> <TestRunParameters> - <Parameter name="PackagedContext" value="true" /> - <Parameter name="VerboseLogging" value="false" /> - <Parameter name="AICLIPath" value="AppInst.exe" /> - <Parameter name="AICLIPackagePath" value="AppInstallerCLIPackage.appxbundle" /> - <Parameter name="LooseFileRegistration" value="false" /> - <Parameter name="InvokeCommandInDesktopPackage" value="false" /> - <Parameter name="StaticFileRootPath" value="\TestLocalIndex" /> - <Parameter name="MsiTestInstallerPath" value="MsiTestInstaller.msi" /> - <Parameter name="MsixTestInstallerPath" value="MsixTestInstaller.msix" /> - <Parameter name="ExeTestInstallerPath" value="ExeTestInstaller.exe" /> - <Parameter name="PackageCertificatePath" value="certificate.pfx"/> - <Parameter name="PowerShellModulePath" value="TestPowerShellModule.psd1" /> + <Parameter name="PackagedContext" value="true" /> + <Parameter name="VerboseLogging" value="false" /> + <Parameter name="AICLIPath" value="AppInst.exe" /> + <Parameter name="AICLIPackagePath" value="AppInstallerCLIPackage.appxbundle" /> + <Parameter name="LooseFileRegistration" value="false" /> + <Parameter name="InvokeCommandInDesktopPackage" value="false" /> + <Parameter name="StaticFileRootPath" value="\TestLocalIndex" /> + <Parameter name="MsiTestInstallerPath" value="MsiTestInstaller.msi" /> + <Parameter name="MsixTestInstallerPath" value="MsixTestInstaller.msix" /> + <Parameter name="ExeTestInstallerPath" value="ExeTestInstaller.exe" /> + <Parameter name="PackageCertificatePath" value="certificate.pfx"/> + <Parameter name="PowerShellModulePath" value="TestPowerShellModule.psd1" /> + <Parameter name="LocalServerCertPathParameter" value="servercert.cer" /> + <Parameter name="SkipTestSource" value="false" /> </TestRunParameters> </RunSettings> \ No newline at end of file diff --git a/src/AppInstallerCLIE2ETests/TestCommon.cs b/src/AppInstallerCLIE2ETests/TestCommon.cs @@ -1,953 +0,0 @@ -// ----------------------------------------------------------------------------- -// <copyright file="TestCommon.cs" company="Microsoft Corporation"> -// Copyright (c) Microsoft Corporation. Licensed under the MIT License. -// </copyright> -// ----------------------------------------------------------------------------- - -namespace AppInstallerCLIE2ETests -{ - using System; - using System.Diagnostics; - using System.IO; - using System.Reflection; - using System.Threading; - using Microsoft.Management.Deployment; - using Microsoft.Win32; - using NUnit.Framework; - using Windows.System; - - /// <summary> - /// Test common. - /// </summary> - public class TestCommon - { - /// <summary> - /// Scope. - /// </summary> - public enum Scope - { - /// <summary> - /// None. - /// </summary> - Unknown, - - /// <summary> - /// User. - /// </summary> - User, - - /// <summary> - /// Machine. - /// </summary> - Machine, - } - - /// <summary> - /// Gets or sets the cli path. - /// </summary> - public static string AICLIPath { get; set; } - - /// <summary> - /// Gets or sets the package path. - /// </summary> - public static string AICLIPackagePath { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether the test runs in package context. - /// </summary> - public static bool PackagedContext { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether the test uses verbose logging. - /// </summary> - public static bool VerboseLogging { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether to use loose file registration. - /// </summary> - public static bool LooseFileRegistration { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether to invoke command in desktop package. - /// </summary> - public static bool InvokeCommandInDesktopPackage { get; set; } - - /// <summary> - /// Gets or sets the static file root path. - /// </summary> - public static string StaticFileRootPath { get; set; } - - /// <summary> - /// Gets or sets the exe installer path. - /// </summary> - public static string ExeInstallerPath { get; set; } - - /// <summary> - /// Gets or sets the msi installer path. - /// </summary> - public static string MsiInstallerPath { get; set; } - - /// <summary> - /// Gets or sets the msix installer path. - /// </summary> - public static string MsixInstallerPath { get; set; } - - /// <summary> - /// Gets or sets the zip installer path. - /// </summary> - public static string ZipInstallerPath { get; set; } - - /// <summary> - /// Gets or sets the package cert path. - /// </summary> - public static string PackageCertificatePath { get; set; } - - /// <summary> - /// Gets or sets the PowerShell module path. - /// </summary> - public static string PowerShellModulePath { get; set; } - - /// <summary> - /// Gets or sets the settings json path. - /// </summary> - public static string SettingsJsonFilePath { get; set; } - - /// <summary> - /// Run winget command. - /// </summary> - /// <param name="command">Command to run.</param> - /// <param name="parameters">Parameters.</param> - /// <param name="stdIn">Optional std in.</param> - /// <param name="timeOut">Optional timeout.</param> - /// <returns>The result of the command.</returns> - public static RunCommandResult RunAICLICommand(string command, string parameters, string stdIn = null, int timeOut = 60000) - { - string inputMsg = - "AICLI path: " + AICLIPath + - " Command: " + command + - " Parameters: " + parameters + - (string.IsNullOrEmpty(stdIn) ? string.Empty : " StdIn: " + stdIn) + - " Timeout: " + timeOut; - - TestContext.Out.WriteLine($"Starting command run. {inputMsg} InvokeCommandInDesktopPackage: {InvokeCommandInDesktopPackage}"); - - if (InvokeCommandInDesktopPackage) - { - return RunAICLICommandViaInvokeCommandInDesktopPackage(command, parameters, stdIn, timeOut); - } - else - { - return RunAICLICommandViaDirectProcess(command, parameters, stdIn, timeOut); - } - } - - /// <summary> - /// Run winget command via direct process. - /// </summary> - /// <param name="command">Command to run.</param> - /// <param name="parameters">Parameters.</param> - /// <param name="stdIn">Optional std in.</param> - /// <param name="timeOut">Optional timeout.</param> - /// <returns>The result of the command.</returns> - public static RunCommandResult RunAICLICommandViaDirectProcess(string command, string parameters, string stdIn = null, int timeOut = 60000) - { - RunCommandResult result = new (); - Process p = new Process(); - p.StartInfo = new ProcessStartInfo(AICLIPath, command + ' ' + parameters); - p.StartInfo.UseShellExecute = false; - p.StartInfo.RedirectStandardOutput = true; - p.StartInfo.RedirectStandardError = true; - - if (!string.IsNullOrEmpty(stdIn)) - { - p.StartInfo.RedirectStandardInput = true; - } - - p.Start(); - - if (!string.IsNullOrEmpty(stdIn)) - { - p.StandardInput.Write(stdIn); - } - - if (p.WaitForExit(timeOut)) - { - result.ExitCode = p.ExitCode; - result.StdOut = p.StandardOutput.ReadToEnd(); - result.StdErr = p.StandardError.ReadToEnd(); - - TestContext.Out.WriteLine("Command run completed with exit code: " + result.ExitCode); - - if (!string.IsNullOrEmpty(result.StdErr)) - { - TestContext.Error.WriteLine("Command run error. Error: " + result.StdErr); - } - - if (VerboseLogging && !string.IsNullOrEmpty(result.StdOut)) - { - TestContext.Out.WriteLine("Command run output. Output: " + result.StdOut); - } - } - else - { - throw new TimeoutException($"Direct winget command run timed out: {command} {parameters}"); - } - - return result; - } - - /// <summary> - /// This method is used when the test is run in an OS that does not support AppExecutionAlias. E,g, our build machine. - /// There is not any existing API that'll activate a packaged app and wait for result, and not possible to capture the stdIn and stdOut. - /// This method tries to call Invoke-CommandInDesktopPackage PS command to make test executable run in packaged context. - /// Since Invoke-CommandInDesktopPackage just launches the executable and return, we use cmd pipe to get execution results. - /// The final constructed command will look like: - /// Invoke-CommandInDesktopPackage ...... -Command cmd.exe -Args '-c [cmd command]' - /// where [cmd command] will look like: "echo stdIn | appinst.exe args > stdout.txt 2> stderr.txt &amp; echo %ERRORLEVEL% > exitcode.txt" - /// Then this method will read the piped result and return as RunCommandResult. - /// </summary> - /// <param name="command">Command to run.</param> - /// <param name="parameters">Parameters.</param> - /// <param name="stdIn">Optional std in.</param> - /// <param name="timeOut">Optional timeout.</param> - /// <param name="throwOnTimeout">Throw on timeout.</param> - /// <returns>The result of the command.</returns> - public static RunCommandResult RunAICLICommandViaInvokeCommandInDesktopPackage(string command, string parameters, string stdIn = null, int timeOut = 60000, bool throwOnTimeout = true) - { - string cmdCommandPiped = string.Empty; - if (!string.IsNullOrEmpty(stdIn)) - { - cmdCommandPiped += $"echo {stdIn} | "; - } - - string workDirectory = GetRandomTestDir(); - string tempBatchFile = Path.Combine(workDirectory, "Batch.cmd"); - string exitCodeFile = Path.Combine(workDirectory, "ExitCode.txt"); - string stdOutFile = Path.Combine(workDirectory, "StdOut.txt"); - string stdErrFile = Path.Combine(workDirectory, "StdErr.txt"); - - // First change the codepage so that the rest of the batch file works - cmdCommandPiped += $"chcp 65001\n{AICLIPath} {command} {parameters} > {stdOutFile} 2> {stdErrFile}\necho %ERRORLEVEL% > {exitCodeFile}"; - File.WriteAllText(tempBatchFile, cmdCommandPiped, new System.Text.UTF8Encoding(false)); - - string psCommand = $"Invoke-CommandInDesktopPackage -PackageFamilyName {Constants.AICLIPackageFamilyName} -AppId {Constants.AICLIAppId} -PreventBreakaway -Command cmd.exe -Args '/c \"{tempBatchFile}\"'"; - - var psInvokeResult = RunCommandWithResult("powershell", psCommand); - - if (psInvokeResult.ExitCode != 0) - { - // PS invocation failed, return result and no need to check piped output. - return psInvokeResult; - } - - // The PS command just launches the app and immediately returns, we'll have to wait for up to the timeOut specified here - int waitedTime = 0; - while (!File.Exists(exitCodeFile) && waitedTime <= timeOut) - { - Thread.Sleep(1000); - waitedTime += 1000; - } - - if (waitedTime >= timeOut && throwOnTimeout) - { - throw new TimeoutException($"Packaged winget command run timed out: {command} {parameters}"); - } - - RunCommandResult result = new (); - - // Sometimes the files are still in use; allow for this with a wait and retry loop. - for (int retryCount = 0; retryCount < 4; ++retryCount) - { - bool success = false; - - try - { - result.ExitCode = File.Exists(exitCodeFile) ? int.Parse(File.ReadAllText(exitCodeFile).Trim()) : unchecked((int)0x80004005); - result.StdOut = File.Exists(stdOutFile) ? File.ReadAllText(stdOutFile) : string.Empty; - result.StdErr = File.Exists(stdErrFile) ? File.ReadAllText(stdErrFile) : string.Empty; - success = true; - } - catch (Exception e) - { - TestContext.Out.WriteLine("Failed to access files: " + e.Message); - } - - if (success) - { - break; - } - else - { - Thread.Sleep(250); - } - } - - return result; - } - - /// <summary> - /// Run command. - /// </summary> - /// <param name="fileName">File name.</param> - /// <param name="args">Args.</param> - /// <param name="timeOut">Time out.</param> - /// <param name="throwOnFailure">If true, throw instead of returning false on a failure.</param> - /// <returns>True if exit code is 0.</returns> - public static bool RunCommand(string fileName, string args = "", int timeOut = 60000, bool throwOnFailure = false) - { - RunCommandResult result = RunCommandWithResult(fileName, args, timeOut); - - if (result.ExitCode != 0) - { - TestContext.Out.WriteLine($"Command failed with: {result.ExitCode}"); - if (throwOnFailure) - { - throw new RunCommandException(fileName, args, result); - } - - return false; - } - else - { - return true; - } - } - - /// <summary> - /// Run command with result. - /// </summary> - /// <param name="fileName">File name.</param> - /// <param name="args">Args.</param> - /// <param name="timeOut">Optional timeout.</param> - /// <returns>Command result.</returns> - public static RunCommandResult RunCommandWithResult(string fileName, string args, int timeOut = 60000) - { - TestContext.Out.WriteLine($"Running command: {fileName} {args}"); - - Process p = new Process(); - p.StartInfo = new ProcessStartInfo(fileName, args); - p.StartInfo.RedirectStandardOutput = true; - p.StartInfo.RedirectStandardError = true; - p.Start(); - - RunCommandResult result = new (); - if (p.WaitForExit(timeOut)) - { - result.ExitCode = p.ExitCode; - result.StdOut = p.StandardOutput.ReadToEnd(); - result.StdErr = p.StandardError.ReadToEnd(); - - if (VerboseLogging) - { - TestContext.Out.WriteLine($"Command run finished. {fileName} {args} {timeOut}. Output: {result.StdOut} Error: {result.StdErr}"); - } - } - else - { - throw new TimeoutException($"Command run timed out. {fileName} {args} {timeOut}"); - } - - return result; - } - - /// <summary> - /// Run PowerShell Core command with result. - /// </summary> - /// <param name="cmdlet">Cmdlet to run.</param> - /// <param name="args">Args.</param> - /// <param name="timeOut">Optional timeout.</param> - /// <returns>Command result.</returns> - public static RunCommandResult RunPowerShellCoreCommandWithResult(string cmdlet, string args, int timeOut = 60000) - { - return RunCommandWithResult("pwsh.exe", $"-Command ipmo {PowerShellModulePath}; {cmdlet} {args}", timeOut); - } - - /// <summary> - /// Get test file path. - /// </summary> - /// <param name="fileName">Test file name.</param> - /// <returns>Path of test file.</returns> - public static string GetTestFile(string fileName) - { - return Path.Combine(TestContext.CurrentContext.TestDirectory, fileName); - } - - /// <summary> - /// Get test data file path. - /// </summary> - /// <param name="fileName">File name.</param> - /// <returns>Test file data path.</returns> - public static string GetTestDataFile(string fileName) - { - return GetTestFile(Path.Combine("TestData", fileName)); - } - - /// <summary> - /// Get test work directory. Creates if not exists. - /// </summary> - /// <returns>The work directory.</returns> - public static string GetTestWorkDir() - { - string workDir = Path.Combine(TestContext.CurrentContext.TestDirectory, "WorkDirectory"); - Directory.CreateDirectory(workDir); - return workDir; - } - - /// <summary> - /// Create random test directory. - /// </summary> - /// <returns>Path of new test directory.</returns> - public static string GetRandomTestDir() - { - string randDir = Path.Combine(GetTestWorkDir(), Path.GetRandomFileName()); - Directory.CreateDirectory(randDir); - return randDir; - } - - /// <summary> - /// Creates new random file name. File is not created. - /// </summary> - /// <param name="extension">Extension of random file.</param> - /// <returns>Path of random file.</returns> - public static string GetRandomTestFile(string extension) - { - return Path.Combine(GetTestWorkDir(), Path.GetRandomFileName() + extension); - } - - /// <summary> - /// Install msix package via PowerShell. - /// </summary> - /// <param name="file">Msix file.</param> - /// <returns>True if installed.</returns> - public static bool InstallMsix(string file) - { - return RunCommand("powershell", $"Add-AppxPackage \"{file}\"", throwOnFailure: true); - } - - /// <summary> - /// Install and register msix package via appx manifest. - /// </summary> - /// <param name="packagePath">Path to package.</param> - /// <param name="forceShutdown">Force shutdown.</param> - /// <param name="throwOnFailure">Throw on failure.</param> - /// <returns>True if installed correctly.</returns> - public static bool InstallMsixRegister(string packagePath, bool forceShutdown = false, bool throwOnFailure = true) - { - string manifestFile = Path.Combine(packagePath, "AppxManifest.xml"); - - var command = $"Add-AppxPackage -Register \"{manifestFile}\""; - if (forceShutdown) - { - command += " -ForceTargetApplicationShutdown"; - } - - return RunCommand("powershell", command, throwOnFailure: throwOnFailure); - } - - /// <summary> - /// Remove msix package. - /// </summary> - /// <param name="name">Package to remove.</param> - /// <param name="isProvisioned">Whether the package is provisioned.</param> - /// <returns>True if removed correctly.</returns> - public static bool RemoveMsix(string name, bool isProvisioned = false) - { - if (isProvisioned) - { - return RunCommand("powershell", $"Get-AppxProvisionedPackage -Online | Where-Object {{$_.PackageName -like \"*{name}*\"}} | Remove-AppxProvisionedPackage -Online -AllUsers") && - RunCommand("powershell", $"Get-AppxPackage \"{name}\" | Remove-AppxPackage -AllUsers"); - } - else - { - return RunCommand("powershell", $"Get-AppxPackage \"{name}\" | Remove-AppxPackage"); - } - } - - /// <summary> - /// Gets the portable symlink directory. - /// </summary> - /// <param name="scope">Scope.</param> - /// <returns>The path of the symlinks.</returns> - public static string GetPortableSymlinkDirectory(Scope scope) - { - if (scope == Scope.User) - { - return Path.Combine(Environment.GetEnvironmentVariable("LocalAppData"), "Microsoft", "WinGet", "Links"); - } - else - { - return Path.Combine(Environment.GetEnvironmentVariable("ProgramFiles"), "WinGet", "Links"); - } - } - - /// <summary> - /// Gets the portable package directory. - /// </summary> - /// <returns>The portable package directory.</returns> - public static string GetPortablePackagesDirectory() - { - return Path.Combine(Environment.GetEnvironmentVariable("LocalAppData"), "Microsoft", "WinGet", "Packages"); - } - - /// <summary> - /// Gets the default download directory for the download command. - /// </summary> - /// <returns>The default download directory.</returns> - public static string GetDefaultDownloadDirectory() - { - return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"); - } - - /// <summary> - /// Verify portable package. - /// </summary> - /// <param name="installDir">Install dir.</param> - /// <param name="commandAlias">Command alias.</param> - /// <param name="filename">File name.</param> - /// <param name="productCode">Product code.</param> - /// <param name="shouldExist">Should exists.</param> - /// <param name="scope">Scope.</param> - public static void VerifyPortablePackage( - string installDir, - string commandAlias, - string filename, - string productCode, - bool shouldExist, - Scope scope = Scope.User) - { - string exePath = Path.Combine(installDir, filename); - bool exeExists = File.Exists(exePath); - - string symlinkDirectory = GetPortableSymlinkDirectory(scope); - string symlinkPath = Path.Combine(symlinkDirectory, commandAlias); - bool symlinkExists = File.Exists(symlinkPath); - - bool portableEntryExists; - RegistryKey baseKey = (scope == Scope.User) ? Registry.CurrentUser : Registry.LocalMachine; - string uninstallSubKey = Constants.UninstallSubKey; - using (RegistryKey uninstallRegistryKey = baseKey.OpenSubKey(uninstallSubKey, true)) - { - RegistryKey portableEntry = uninstallRegistryKey.OpenSubKey(productCode, true); - portableEntryExists = portableEntry != null; - } - - bool isAddedToPath; - string pathSubKey = (scope == Scope.User) ? Constants.PathSubKey_User : Constants.PathSubKey_Machine; - using (RegistryKey environmentRegistryKey = baseKey.OpenSubKey(pathSubKey, true)) - { - string pathName = "Path"; - var currentPathValue = (string)environmentRegistryKey.GetValue(pathName); - var portablePathValue = symlinkDirectory + ';'; - isAddedToPath = currentPathValue.Contains(portablePathValue); - } - - if (shouldExist) - { - RunAICLICommand("uninstall", $"--product-code {productCode} --force"); - } - - Assert.AreEqual(shouldExist, exeExists, $"Expected portable exe path: {exePath}"); - Assert.AreEqual(shouldExist, symlinkExists, $"Expected portable symlink path: {symlinkPath}"); - Assert.AreEqual(shouldExist, portableEntryExists, $"Expected {productCode} subkey in path: {uninstallSubKey}"); - Assert.AreEqual(shouldExist, isAddedToPath, $"Expected path variable: {symlinkDirectory}"); - } - - /// <summary> - /// Copies log files to the path %TEMP%\E2ETestLogs. - /// </summary> - public static void PublishE2ETestLogs() - { - string tempPath = Path.GetTempPath(); - string localAppDataPath = Environment.GetEnvironmentVariable("LocalAppData"); - string testLogsPackagedSourcePath = Path.Combine(localAppDataPath, Constants.E2ETestLogsPathPackaged); - string testLogsUnpackagedSourcePath = Path.Combine(tempPath, Constants.E2ETestLogsPathUnpackaged); - string testLogsDestPath = Path.Combine(tempPath, "E2ETestLogs"); - string testLogsPackagedDestPath = Path.Combine(testLogsDestPath, "Packaged"); - string testLogsUnpackagedDestPath = Path.Combine(testLogsDestPath, "Unpackaged"); - - if (Directory.Exists(testLogsPackagedSourcePath)) - { - TestIndexSetup.CopyDirectory(testLogsPackagedSourcePath, testLogsPackagedDestPath); - } - - if (Directory.Exists(testLogsUnpackagedSourcePath)) - { - TestIndexSetup.CopyDirectory(testLogsUnpackagedSourcePath, testLogsUnpackagedDestPath); - } - } - - /// <summary> - /// Gets the server certificate as a hex string. - /// </summary> - /// <returns>Hex string.</returns> - public static string GetTestServerCertificateHexString() - { - return Convert.ToHexString(File.ReadAllBytes(Path.Combine(StaticFileRootPath, Constants.TestSourceServerCertificateFileName))); - } - - /// <summary> - /// Verify exe installer correctly. - /// </summary> - /// <param name="installDir">Install directory.</param> - /// <param name="expectedContent">Optional expected content.</param> - /// <returns>True if success.</returns> - public static bool VerifyTestExeInstalled(string installDir, string expectedContent = null) - { - bool verifyInstallSuccess = true; - - if (!File.Exists(Path.Combine(installDir, Constants.TestExeInstalledFileName))) - { - TestContext.Out.WriteLine($"TestExeInstalled.exe not found at {installDir}"); - verifyInstallSuccess = false; - } - - if (verifyInstallSuccess && !string.IsNullOrEmpty(expectedContent)) - { - string content = File.ReadAllText(Path.Combine(installDir, Constants.TestExeInstalledFileName)); - TestContext.Out.WriteLine($"TestExeInstalled.exe content: {content}"); - verifyInstallSuccess = content.Contains(expectedContent); - } - - return verifyInstallSuccess; - } - - /// <summary> - /// Verify installer downloaded correctly and cleanup. - /// </summary> - /// <param name="downloadDir">Download directory.</param> - /// <param name="name">Package name.</param> - /// <param name="version">Package version.</param> - /// <param name="arch">Installer architecture.</param> - /// <param name="scope">Installer scope.</param> - /// <param name="installerType">Installer type.</param> - /// <param name="locale">Installer locale.</param> - /// <param name="isArchive">Boolean value indicating whether the installer is an archive.</param> - /// <param name="cleanup">Boolean value indicating whether to remove the installer file and directory.</param> - /// <returns>True if success.</returns> - public static bool VerifyInstallerDownload( - string downloadDir, - string name, - string version, - Windows.System.ProcessorArchitecture arch, - Scope scope, - PackageInstallerType installerType, - string locale = null, - bool isArchive = false, - bool cleanup = true) - { - string expectedFileName = $"{name}_{version}"; - - if (scope != Scope.Unknown) - { - expectedFileName += $"_{scope}"; - } - - expectedFileName += $"_{arch}_{installerType}"; - - if (!string.IsNullOrEmpty(locale)) - { - expectedFileName += $"_{locale}"; - } - - string extension; - if (isArchive) - { - extension = ".zip"; - } - else - { - extension = installerType switch - { - PackageInstallerType.Msi => ".msi", - PackageInstallerType.Msix => ".msix", - _ => ".exe" - }; - } - - expectedFileName += extension; - string installerDownloadPath = Path.Combine(downloadDir, expectedFileName); - - bool downloadResult = false; - - if (Directory.Exists(downloadDir) && File.Exists(installerDownloadPath)) - { - downloadResult = true; - - if (cleanup) - { - File.Delete(installerDownloadPath); - Directory.Delete(downloadDir, true); - } - } - - return downloadResult; - } - - /// <summary> - /// Verify exe installer correctly and then uninstall it. - /// </summary> - /// <param name="installDir">Install directory.</param> - /// <param name="expectedContent">Optional expected content.</param> - /// <returns>True if success.</returns> - public static bool VerifyTestExeInstalledAndCleanup(string installDir, string expectedContent = null) - { - bool verifyInstallSuccess = VerifyTestExeInstalled(installDir, expectedContent); - - // Always try clean up and ignore clean up failure - var uninstallerPath = Path.Combine(installDir, Constants.TestExeUninstallerFileName); - if (File.Exists(uninstallerPath)) - { - RunCommand(Path.Combine(installDir, Constants.TestExeUninstallerFileName)); - } - - return verifyInstallSuccess; - } - - /// <summary> - /// Verify msi installed correctly. - /// </summary> - /// <param name="installDir">Installed directory.</param> - /// <returns>True if success.</returns> - public static bool VerifyTestMsiInstalledAndCleanup(string installDir) - { - string pathToCheck = Path.Combine(installDir, Constants.AppInstallerTestExeInstallerExe); - if (!File.Exists(pathToCheck)) - { - TestContext.Out.WriteLine($"File not found: {pathToCheck}"); - return false; - } - - return RunCommand("msiexec.exe", $"/qn /x {Constants.MsiInstallerProductCode}"); - } - - /// <summary> - /// Verify msix installed correctly. - /// </summary> - /// <param name="isProvisioned">Whether the package is provisioned.</param> - /// <returns>True if success.</returns> - public static bool VerifyTestMsixInstalledAndCleanup(bool isProvisioned = false) - { - var result = RunCommandWithResult("powershell", $"Get-AppxPackage {Constants.MsixInstallerName}"); - - if (!result.StdOut.Contains(Constants.MsixInstallerName)) - { - return false; - } - - if (isProvisioned) - { - result = RunCommandWithResult("powershell", $"Get-AppxProvisionedPackage -Online | Where-Object {{$_.PackageName -like \"*{Constants.MsixInstallerName}*\"}}"); - if (!result.StdOut.Contains(Constants.MsixInstallerName)) - { - return false; - } - } - - return RemoveMsix(Constants.MsixInstallerName, isProvisioned); - } - - /// <summary> - /// Verify test exe uninstalled. - /// </summary> - /// <param name="installDir">Installed directory.</param> - /// <returns>True if success.</returns> - public static bool VerifyTestExeUninstalled(string installDir) - { - return File.Exists(Path.Combine(installDir, Constants.TestExeUninstalledFileName)); - } - - /// <summary> - /// Verify msi uninstalled. - /// </summary> - /// <param name="installDir">Install directory.</param> - /// <returns>True if success.</returns> - public static bool VerifyTestMsiUninstalled(string installDir) - { - return !File.Exists(Path.Combine(installDir, Constants.AppInstallerTestExeInstallerExe)); - } - - /// <summary> - /// Verify msix uninstalled. - /// </summary> - /// <param name="isProvisioned">Whether the package is provisioned.</param> - /// <returns>True if success.</returns> - public static bool VerifyTestMsixUninstalled(bool isProvisioned = false) - { - bool isUninstalled = false; - var result = RunCommandWithResult("powershell", $"Get-AppxPackage {Constants.MsixInstallerName}"); - isUninstalled = string.IsNullOrWhiteSpace(result.StdOut); - - if (isProvisioned) - { - result = RunCommandWithResult("powershell", $"Get-AppxProvisionedPackage -Online | Where-Object {{$_.PackageName -like \"*{Constants.MsixInstallerName}*\"}}"); - isUninstalled = isUninstalled && string.IsNullOrWhiteSpace(result.StdOut); - } - - return isUninstalled; - } - - /// <summary> - /// Modify uninstalled registry key. - /// </summary> - /// <param name="productCode">Product code.</param> - /// <param name="name">Name.</param> - /// <param name="value">Value.</param> - public static void ModifyPortableARPEntryValue(string productCode, string name, string value) - { - using (RegistryKey uninstallRegistryKey = Registry.CurrentUser.OpenSubKey(Constants.UninstallSubKey, true)) - { - RegistryKey entry = uninstallRegistryKey.OpenSubKey(productCode, true); - entry.SetValue(name, value); - } - } - - /// <summary> - /// Set up test source. - /// </summary> - /// <param name="useGroupPolicyForTestSource">Use group policy.</param> - public static void SetupTestSource(bool useGroupPolicyForTestSource = false) - { - TestCommon.RunAICLICommand("source reset", "--force"); - TestCommon.RunAICLICommand("source remove", Constants.DefaultWingetSourceName); - TestCommon.RunAICLICommand("source remove", Constants.DefaultMSStoreSourceName); - - // TODO: If/when cert pinning is implemented on the packaged index source, useGroupPolicyForTestSource should be set to default true - // to enable testing it by default. Until then, leaving this here... - if (useGroupPolicyForTestSource) - { - GroupPolicyHelper.EnableAdditionalSources.SetEnabledList(new GroupPolicyHelper.GroupPolicySource[] - { - new GroupPolicyHelper.GroupPolicySource - { - Name = Constants.TestSourceName, - Arg = Constants.TestSourceUrl, - Type = Constants.TestSourceType, - Data = Constants.TestSourceIdentifier, - Identifier = Constants.TestSourceIdentifier, - CertificatePinning = new GroupPolicyHelper.GroupPolicyCertificatePinning - { - Chains = new GroupPolicyHelper.GroupPolicyCertificatePinningChain[] - { - new GroupPolicyHelper.GroupPolicyCertificatePinningChain - { - Chain = new GroupPolicyHelper.GroupPolicyCertificatePinningDetails[] - { - new GroupPolicyHelper.GroupPolicyCertificatePinningDetails - { - Validation = new string[] { "publickey" }, - EmbeddedCertificate = TestCommon.GetTestServerCertificateHexString(), - }, - }, - }, - }, - }, - }, - }); - } - else - { - GroupPolicyHelper.EnableAdditionalSources.SetNotConfigured(); - TestCommon.RunAICLICommand("source add", $"{Constants.TestSourceName} {Constants.TestSourceUrl}"); - } - - Thread.Sleep(2000); - } - - /// <summary> - /// Tear down test source. - /// </summary> - public static void TearDownTestSource() - { - RunAICLICommand("source remove", Constants.TestSourceName); - RunAICLICommand("source reset", "--force"); - } - - /// <summary> - /// Ensures that a module is in the desired state. - /// </summary> - /// <param name="moduleName">The module.</param> - /// <param name="present">Whether the module is present or not.</param> - /// <param name="repository">The repository to get the module from if needed.</param> - public static void EnsureModuleState(string moduleName, bool present, string repository = null) - { - var result = RunCommandWithResult("pwsh", $"-Command \"Get-Module {moduleName} -ListAvailable\""); - bool isPresent = !string.IsNullOrWhiteSpace(result.StdOut); - - if (isPresent && !present) - { - RunCommand("pwsh", $"-Command \"Uninstall-Module {moduleName}\""); - } - else if (!isPresent && present) - { - if (string.IsNullOrEmpty(repository)) - { - RunCommand("pwsh", $"-Command \"Install-Module {moduleName} -Force\""); - } - else - { - RunCommand("pwsh", $"-Command \"Install-Module {moduleName} -Repository {repository} -Force\""); - } - } - } - - /// <summary> - /// Creates an ARP entry from the given values. - /// </summary> - /// <param name="productCode">Product code of the entry.</param> - /// <param name="properties">The properties to set in the entry.</param> - /// <param name="scope">Scope of the entry.</param> - public static void CreateARPEntry( - string productCode, - object properties, - Scope scope = Scope.User) - { - RegistryKey baseKey = (scope == Scope.User) ? Registry.CurrentUser : Registry.LocalMachine; - using (RegistryKey uninstallRegistryKey = baseKey.OpenSubKey(Constants.UninstallSubKey, true)) - { - RegistryKey entry = uninstallRegistryKey.CreateSubKey(productCode, true); - - foreach (PropertyInfo property in properties.GetType().GetProperties()) - { - entry.SetValue(property.Name, property.GetValue(properties)); - } - } - } - - /// <summary> - /// Removes an ARP entry. - /// </summary> - /// <param name="productCode">Product code of the entry.</param> - /// <param name="scope">Scope of the entry.</param> - public static void RemoveARPEntry( - string productCode, - Scope scope = Scope.User) - { - RegistryKey baseKey = (scope == Scope.User) ? Registry.CurrentUser : Registry.LocalMachine; - using (RegistryKey uninstallRegistryKey = baseKey.OpenSubKey(Constants.UninstallSubKey, true)) - { - uninstallRegistryKey.DeleteSubKey(productCode); - } - } - - /// <summary> - /// Run command result. - /// </summary> - public struct RunCommandResult - { - /// <summary> - /// Exit code. - /// </summary> - public int ExitCode; - - /// <summary> - /// StdOut. - /// </summary> - public string StdOut; - - /// <summary> - /// StdErr. - /// </summary> - public string StdErr; - } - } -} diff --git a/src/AppInstallerCLIE2ETests/TestData/Package/AppxManifest.xml b/src/AppInstallerCLIE2ETests/TestData/Package/AppxManifest.xml @@ -9,10 +9,8 @@ Publisher="CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US" Version="2020.805.713.335" /> - <!-- Commit in which this package was based on 0563b4dd28f58985a8e60762ede9824f1b7ea310--> - <Properties> - <DisplayName>WinGet Source</DisplayName> + <DisplayName>WinGet E2E Source</DisplayName> <PublisherDisplayName>Microsoft Corporation</PublisherDisplayName> <Logo>Assets\AppPackageStoreLogo.png</Logo> </Properties> @@ -23,10 +21,10 @@ <Applications> <Application Id="SourceData"> - <uap:VisualElements DisplayName="WinGet Source" + <uap:VisualElements DisplayName="WinGet E2E Source" Square150x150Logo="Assets\AppPackageStoreLogo.scale-150.png" Square44x44Logo="Assets\AppPackageStoreLogo.scale-100.png" - Description="WinGet Source" + Description="WinGet E2E Source" BackgroundColor="#0078d7" AppListEntry="none" /> <Extensions> diff --git a/src/AppInstallerCLIE2ETests/TestData/localsource.json b/src/AppInstallerCLIE2ETests/TestData/localsource.json @@ -0,0 +1,43 @@ +{ + "AppxManifest": "%BUILD_SOURCESDIRECTORY%/src/AppInstallerCLIE2ETests/TestData/Package/AppxManifest.xml", + "WorkingDirectory": "%AGENT_TEMPDIRECTORY%/TestLocalIndex", + "LocalManifests": [ + "%BUILD_SOURCESDIRECTORY%/src/AppInstallerCLIE2ETests/TestData/Manifests" + ], + "LocalInstallers": [ + { + "Type": "exe", + "Name": "AppInstallerTestExeInstaller/AppInstallerTestExeInstaller.exe", + "Input": "%BUILDOUTDIR%/AppInstallerTestExeInstaller/AppInstallerTestExeInstaller.exe", + "HashToken": "<EXEHASH>" + }, + { + "Type": "msi", + "Name": "AppInstallerTestMsiInstaller/AppInstallerTestMsiInstaller.msi", + "Input": "%BUILD_SOURCESDIRECTORY%/src/AppInstallerCLIE2ETests/TestData/AppInstallerTestMsiInstaller.msi", + "HashToken": "<MSIHASH>" + }, + { + "Type": "msix", + "Name": "AppInstallerTestMsixInstaller/AppInstallerTestMsixInstaller.msix", + "Input": "%BUILD_ARTIFACTSTAGINGDIRECTORY%/AppInstallerTestMsixInstaller.msix", + "HashToken": "<MSIXHASH>", + "SignatureToken": "<SIGNATUREHASH>" + } + ], + "DynamicInstallers": [ + { + "Type": "zip", + "Name": "AppInstallerTestZipInstaller/AppInstallerTestZipInstaller.zip", + "Input": [ + "%AGENT_TEMPDIRECTORY%/TestLocalIndex/AppInstallerTestExeInstaller/AppInstallerTestExeInstaller.exe", + "%AGENT_TEMPDIRECTORY%/TestLocalIndex/AppInstallerTestMsiInstaller/AppInstallerTestMsiInstaller.msi", + "%AGENT_TEMPDIRECTORY%/TestLocalIndex/AppInstallerTestMsixInstaller/AppInstallerTestMsixInstaller.msix" + ], + "HashToken": "<ZIPHASH>" + } + ], + "Signature": { + "CertFile": "%APPINSTALLERTEST_SECUREFILEPATH%" + } +} diff --git a/src/AppInstallerCLIE2ETests/TestHashHelper.cs b/src/AppInstallerCLIE2ETests/TestHashHelper.cs @@ -1,210 +0,0 @@ -// ----------------------------------------------------------------------------- -// <copyright file="TestHashHelper.cs" company="Microsoft Corporation"> -// Copyright (c) Microsoft Corporation. Licensed under the MIT License. -// </copyright> -// ----------------------------------------------------------------------------- - -namespace AppInstallerCLIE2ETests -{ - using System; - using System.IO; - using System.Security.Cryptography; - using Microsoft.Msix.Utils.ProcessRunner; - - /// <summary> - /// TestHashHelper. - /// </summary> - public class TestHashHelper - { - /// <summary> - /// Gets or sets the exe installer hash value. - /// </summary> - public static string ExeInstallerHashValue { get; set; } - - /// <summary> - /// Gets or sets the msi installer hash value. - /// </summary> - public static string MsiInstallerHashValue { get; set; } - - /// <summary> - /// Gets or sets the msix installer hash value. - /// </summary> - public static string MsixInstallerHashValue { get; set; } - - /// <summary> - /// Gets or sets the zip installer hash value. - /// </summary> - public static string ZipInstallerHashValue { get; set; } - - /// <summary> - /// Gets or sets the signature hash value. - /// </summary> - public static string SignatureHashValue { get; set; } - - /// <summary> - /// Sets the hash of the installers. - /// </summary> - public static void HashInstallers() - { - if (!string.IsNullOrEmpty(TestCommon.ExeInstallerPath)) - { - ExeInstallerHashValue = HashFile(TestCommon.ExeInstallerPath); - } - - if (!string.IsNullOrEmpty(TestCommon.MsiInstallerPath)) - { - MsiInstallerHashValue = HashFile(TestCommon.MsiInstallerPath); - } - - if (!string.IsNullOrEmpty(TestCommon.MsixInstallerPath)) - { - MsixInstallerHashValue = HashFile(TestCommon.MsixInstallerPath); - SignatureHashValue = HashSignatureFromMSIX(TestCommon.MsixInstallerPath); - } - - if (!string.IsNullOrEmpty(TestCommon.ZipInstallerPath)) - { - ZipInstallerHashValue = HashFile(TestCommon.ZipInstallerPath); - } - } - - /// <summary> - /// Iterates through all manifest files in a directory and replaces the hash token with the - /// corresponding installer hash token. - /// </summary> - /// <param name="pathToManifestDir">Path to manifest directory.</param> - public static void ReplaceManifestHashToken(string pathToManifestDir) - { - var dir = new DirectoryInfo(pathToManifestDir); - FileInfo[] files = dir.GetFiles(); - - foreach (FileInfo file in files) - { - string text = File.ReadAllText(file.FullName); - - if (text.Contains("<EXEHASH>")) - { - text = text.Replace("<EXEHASH>", ExeInstallerHashValue); - File.WriteAllText(file.FullName, text); - } - - if (text.Contains("<MSIHASH>")) - { - text = text.Replace("<MSIHASH>", MsiInstallerHashValue); - File.WriteAllText(file.FullName, text); - } - - if (text.Contains("<MSIXHASH>")) - { - text = text.Replace("<MSIXHASH>", MsixInstallerHashValue); - - if (text.Contains("<SIGNATUREHASH>")) - { - text = text.Replace("<SIGNATUREHASH>", SignatureHashValue); - } - - File.WriteAllText(file.FullName, text); - } - - if (text.Contains("<ZIPHASH>")) - { - text = text.Replace("<ZIPHASH>", ZipInstallerHashValue); - File.WriteAllText(file.FullName, text); - } - } - } - - /// <summary> - /// Gets hash of the AppxSignature.p7x file in the msix. - /// </summary> - /// <param name="packageFilePath">Package file path.</param> - /// <returns>Hash of signature file.</returns> - public static string HashSignatureFromMSIX(string packageFilePath) - { - // Obtain MakeAppX Executable Path - string pathToSDK = SDKDetector.Instance.LatestSDKBinPath; - string makeappxExecutable = Path.Combine(pathToSDK, "makeappx.exe"); - - // Generate temp path to unpack MSIX package - FileInfo fileInfo = new FileInfo(packageFilePath); - string packageName = Path.GetFileNameWithoutExtension(fileInfo.Name); - string tempPath = Path.GetTempPath(); - string extractedPackageDest = Path.Combine(tempPath, packageName); - - // Delete existing extracted package directories to avoid stalling MakeAppX command - if (Directory.Exists(extractedPackageDest)) - { - TestIndexSetup.DeleteDirectoryContents(Directory.CreateDirectory(extractedPackageDest)); - Directory.Delete(extractedPackageDest); - } - - TestIndexSetup.RunCommand(makeappxExecutable, $"unpack /nv /p {packageFilePath} /d {extractedPackageDest}"); - - string packageSignaturePath = Path.Combine(extractedPackageDest, "AppxSignature.p7x"); - return HashFile(packageSignaturePath); - } - - /// <summary> - /// Gets the hash of the specified file. - /// </summary> - /// <param name="filePath">File path.</param> - /// <returns>Hash of file.</returns> - public static string HashFile(string filePath) - { - FileInfo file; - - try - { - file = new FileInfo(filePath); - } - catch (FileNotFoundException e) - { - Console.WriteLine($"File Not Found: {e.Message}"); - throw; - } - - string hash = string.Empty; - - using (SHA256 mySHA256 = SHA256.Create()) - { - try - { - FileStream fileStream = file.Open(FileMode.Open); - fileStream.Position = 0; - byte[] hashValue = mySHA256.ComputeHash(fileStream); - hash = ConvertHashByteToString(hashValue); - fileStream.Close(); - } - catch (IOException e) - { - Console.WriteLine($"I/O Exception: {e.Message}"); - throw; - } - catch (UnauthorizedAccessException e) - { - Console.WriteLine($"Access Exception: {e.Message}"); - throw; - } - } - - return hash; - } - - /// <summary> - /// Converts the byte hash into its string format. - /// </summary> - /// <param name="array">Hash.</param> - /// <returns>Hash as string.</returns> - public static string ConvertHashByteToString(byte[] array) - { - string hashValue = string.Empty; - - for (int i = 0; i < array.Length; i++) - { - hashValue = hashValue + $"{array[i]:X2}"; - } - - return hashValue; - } - } -} diff --git a/src/AppInstallerCLIE2ETests/TestIndexSetup.cs b/src/AppInstallerCLIE2ETests/TestIndexSetup.cs @@ -1,289 +0,0 @@ -// ----------------------------------------------------------------------------- -// <copyright file="TestIndexSetup.cs" company="Microsoft Corporation"> -// Copyright (c) Microsoft Corporation. Licensed under the MIT License. -// </copyright> -// ----------------------------------------------------------------------------- - -namespace AppInstallerCLIE2ETests -{ - using System; - using System.Diagnostics; - using System.IO; - using System.IO.Compression; - using Microsoft.Msix.Utils.ProcessRunner; - - /// <summary> - /// Test index setup. - /// </summary> - public class TestIndexSetup - { - private const string TestDataName = "TestData"; - private const string ManifestsName = "Manifests"; - private const string PackageName = "Package"; - private const string PublicName = "Public"; - - /// <summary> - /// Generates the Local Test Index to be served by the Localhost Web Server. - /// 1. Copies TestData to a StaticFileRootPath set in Test.runsettings file - /// 2. Copies and signs installer files (EXE or MSIX) - /// 3. Hashes installer Files - /// 4. Replaces manifests with corresponding hash values - /// 5. Generates a source package for TestData using makeappx/signtool. - /// </summary> - public static void GenerateTestDirectory() - { - SetupLocalTestDirectory(TestCommon.StaticFileRootPath); - - if (!string.IsNullOrEmpty(TestCommon.ExeInstallerPath)) - { - CopyExeInstallerToTestDirectory(); - } - - if (!string.IsNullOrEmpty(TestCommon.MsiInstallerPath)) - { - CopyMsiInstallerToTestDirectory(); - } - - if (!string.IsNullOrEmpty(TestCommon.MsixInstallerPath)) - { - CopyMsixInstallerToTestDirectory(); - } - - CreateZipInstallerInTestDirectory(); - - TestHashHelper.HashInstallers(); - - string manifestDirectoryPath = Path.Combine(TestCommon.StaticFileRootPath, ManifestsName); - TestHashHelper.ReplaceManifestHashToken(manifestDirectoryPath); - - SetupSourcePackage(); - } - - /// <summary> - /// Sign a file using signtool.exe . - /// </summary> - /// <param name="filePath">File to sign.</param> - public static void SignFile(string filePath) - { - string pathToSDK = SDKDetector.Instance.LatestSDKBinPath; - string signtoolExecutable = Path.Combine(pathToSDK, "signtool.exe"); - RunCommand(signtoolExecutable, $"sign /a /fd sha256 /f {TestCommon.PackageCertificatePath} {filePath}"); - } - - /// <summary> - /// Deletes the contents of a given directory. - /// </summary> - /// <param name="directory">Directory info.</param> - public static void DeleteDirectoryContents(DirectoryInfo directory) - { - foreach (FileInfo file in directory.GetFiles()) - { - // Leave the server certificate file if present - if (file.Name.ToLower() != Constants.TestSourceServerCertificateFileName) - { - try - { - file.Delete(); - } - catch - { - // Just ignore errors in this setup step... - } - } - } - - foreach (DirectoryInfo dir in directory.GetDirectories()) - { - try - { - dir.Delete(true); - } - catch - { - // Just ignore errors in this setup step... - } - } - } - - /// <summary> - /// Copies the contents of a given directory from a source path to a destination path. - /// </summary> - /// <param name="sourceDirName">Source directory name.</param> - /// <param name="destDirName">Destination directory name.</param> - public static void CopyDirectory(string sourceDirName, string destDirName) - { - DirectoryInfo dir = new DirectoryInfo(sourceDirName); - DirectoryInfo[] dirs = dir.GetDirectories(); - - if (!Directory.Exists(destDirName)) - { - Directory.CreateDirectory(destDirName); - } - - FileInfo[] files = dir.GetFiles(); - foreach (FileInfo file in files) - { - string temppath = Path.Combine(destDirName, file.Name); - file.CopyTo(temppath, false); - } - - foreach (DirectoryInfo subdir in dirs) - { - string temppath = Path.Combine(destDirName, subdir.Name); - CopyDirectory(subdir.FullName, temppath); - } - } - - /// <summary> - /// Run a command. - /// </summary> - /// <param name="command">Command.</param> - /// <param name="args">Arguments.</param> - public static void RunCommand(string command, string args) - { - Process p = new Process(); - p.StartInfo = new ProcessStartInfo(command, args); - p.Start(); - p.WaitForExit(); - } - - /// <summary> - /// Run a command from a working directory. - /// </summary> - /// <param name="command">Command.</param> - /// <param name="args">Arguments.</param> - /// <param name="workingDirectory">Working directory.</param> - public static void RunCommand(string command, string args, string workingDirectory) - { - Process p = new Process(); - p.StartInfo = new ProcessStartInfo(command, args); - p.StartInfo.WorkingDirectory = workingDirectory; - p.Start(); - p.WaitForExit(); - } - - private static void SetupSourcePackage() - { - string indexDestPath = Path.Combine(TestCommon.StaticFileRootPath, PackageName, PublicName); - - DirectoryInfo parentDir = Directory.GetParent(Directory.GetCurrentDirectory()); - string indexCreationToolPath = Path.Combine(parentDir.FullName, Constants.IndexCreationTool); - string winGetUtilPath = Path.Combine(parentDir.FullName, Constants.WinGetUtil); - - // Copy WingetUtil.dll app extension to IndexCreationTool Path - File.Copy(Path.Combine(winGetUtilPath, @"WinGetUtil.dll"), Path.Combine(indexCreationToolPath, @"WinGetUtil.dll"), true); - - try - { - if (!Directory.Exists(indexDestPath)) - { - Directory.CreateDirectory(indexDestPath); - } - - // Generate Index.db file using IndexCreationTool.exe - RunCommand(Path.Combine(indexCreationToolPath, "IndexCreationTool.exe"), $"-d {TestCommon.StaticFileRootPath} -i {ManifestsName}", indexDestPath); - - string packageDir = Path.Combine(TestCommon.StaticFileRootPath, PackageName); - string indexPackageDestPath = Path.Combine(TestCommon.StaticFileRootPath, Constants.IndexPackage); - string pathToSDK = SDKDetector.Instance.LatestSDKBinPath; - - // Package Test Source and Sign With Package Certificate - string makeappxExecutable = Path.Combine(pathToSDK, "makeappx.exe"); - RunCommand(makeappxExecutable, $"pack /nv /v /o /d {packageDir} /p {indexPackageDestPath}"); - SignFile(indexPackageDestPath); - } - catch (Exception e) - { - Console.WriteLine("Failed. Reason: " + e.Message); - } - } - - private static void CopyExeInstallerToTestDirectory() - { - // Set Exe Test Installer Path - string exeInstallerDestPath = Path.Combine(TestCommon.StaticFileRootPath, Constants.ExeInstaller); - DirectoryInfo exeInstallerDestDir = Directory.CreateDirectory(exeInstallerDestPath); - string exeInstallerFullName = Path.Combine(exeInstallerDestDir.FullName, Constants.ExeInstallerFileName); - - // Copy Exe Test Installer to Destination Path - File.Copy(TestCommon.ExeInstallerPath, exeInstallerFullName, true); - TestCommon.ExeInstallerPath = exeInstallerFullName; - - // Sign EXE Installer File - SignFile(TestCommon.ExeInstallerPath); - } - - private static void CopyMsiInstallerToTestDirectory() - { - // Set MSI Test Installer Path - string msiInstallerDestPath = Path.Combine(TestCommon.StaticFileRootPath, Constants.MsiInstaller); - DirectoryInfo msiInstallerDestDir = Directory.CreateDirectory(msiInstallerDestPath); - - // Copy MSI Test Installer to Destination Path - string msiInstallerFullName = Path.Combine(msiInstallerDestDir.FullName, Constants.MsiInstallerFileName); - - File.Copy(TestCommon.MsiInstallerPath, msiInstallerFullName, true); - TestCommon.MsiInstallerPath = msiInstallerFullName; - - // Sign MSI Installer File - SignFile(TestCommon.MsiInstallerPath); - } - - private static void CopyMsixInstallerToTestDirectory() - { - // Set Msix Test Installer Path - string msixInstallerDestPath = Path.Combine(TestCommon.StaticFileRootPath, Constants.MsixInstaller); - DirectoryInfo msixInstallerDestDir = Directory.CreateDirectory(msixInstallerDestPath); - - // Copy Msix Test Installer to Destination Path - string msixInstallerFullName = Path.Combine(msixInstallerDestDir.FullName, Constants.MsixInstallerFileName); - - File.Copy(TestCommon.MsixInstallerPath, msixInstallerFullName, true); - TestCommon.MsixInstallerPath = msixInstallerFullName; - - // Sign MSIX Installer File - SignFile(TestCommon.MsixInstallerPath); - } - - private static void CreateZipInstallerInTestDirectory() - { - DirectoryInfo zipInstallerDir = Directory.CreateDirectory(Path.Combine(TestCommon.StaticFileRootPath, Constants.ZipInstaller)); - string zipSourceDirFullPath = Directory.CreateDirectory(TestCommon.GetRandomTestDir()).FullName; - - string exeInstallerSourceDestPath = Path.Combine(zipSourceDirFullPath, Constants.ExeInstallerFileName); - string msiInstallerSourceDestPath = Path.Combine(zipSourceDirFullPath, Constants.MsiInstallerFileName); - string msixInstallerSourceDestPath = Path.Combine(zipSourceDirFullPath, Constants.MsixInstallerFileName); - - if (File.Exists(TestCommon.ExeInstallerPath)) - { - File.Copy(TestCommon.ExeInstallerPath, exeInstallerSourceDestPath, true); - } - - if (File.Exists(TestCommon.MsiInstallerPath)) - { - File.Copy(TestCommon.MsiInstallerPath, msiInstallerSourceDestPath, true); - } - - if (File.Exists(TestCommon.MsixInstallerPath)) - { - File.Copy(TestCommon.MsixInstallerPath, msixInstallerSourceDestPath, true); - } - - string destArchiveFullPath = Path.Combine(zipInstallerDir.FullName, Constants.ZipInstallerFileName); - ZipFile.CreateFromDirectory(zipSourceDirFullPath, destArchiveFullPath); - TestCommon.ZipInstallerPath = destArchiveFullPath; - } - - private static void SetupLocalTestDirectory(string staticFileRootPath) - { - DirectoryInfo staticFileRootDir = Directory.CreateDirectory(staticFileRootPath); - - DeleteDirectoryContents(staticFileRootDir); - - string currentDirectory = Environment.CurrentDirectory; - string sourcePath = Path.Combine(currentDirectory, TestDataName); - - CopyDirectory(sourcePath, TestCommon.StaticFileRootPath); - } - } -} diff --git a/src/AppInstallerCLIE2ETests/UninstallCommand.cs b/src/AppInstallerCLIE2ETests/UninstallCommand.cs @@ -7,6 +7,7 @@ namespace AppInstallerCLIE2ETests { using System.IO; + using AppInstallerCLIE2ETests.Helpers; using NUnit.Framework; /// <summary> @@ -47,7 +48,7 @@ namespace AppInstallerCLIE2ETests [Test] public void UninstallTestMsi() { - if (string.IsNullOrEmpty(TestCommon.MsiInstallerPath)) + if (string.IsNullOrEmpty(TestIndex.MsiInstaller)) { Assert.Ignore("MSI installer not available"); } diff --git a/src/AppInstallerCLIE2ETests/UpgradeCommand.cs b/src/AppInstallerCLIE2ETests/UpgradeCommand.cs @@ -7,6 +7,7 @@ namespace AppInstallerCLIE2ETests { using System.IO; + using AppInstallerCLIE2ETests.Helpers; using NUnit.Framework; /// <summary> diff --git a/src/AppInstallerCLIE2ETests/ValidateCommand.cs b/src/AppInstallerCLIE2ETests/ValidateCommand.cs @@ -6,6 +6,7 @@ namespace AppInstallerCLIE2ETests { + using AppInstallerCLIE2ETests.Helpers; using NUnit.Framework; /// <summary> diff --git a/src/AppInstallerCLIE2ETests/WinGetSettingsHelper.cs b/src/AppInstallerCLIE2ETests/WinGetSettingsHelper.cs @@ -1,200 +0,0 @@ -// ----------------------------------------------------------------------------- -// <copyright file="WinGetSettingsHelper.cs" company="Microsoft Corporation"> -// Copyright (c) Microsoft Corporation. Licensed under the MIT License. -// </copyright> -// ----------------------------------------------------------------------------- - -namespace AppInstallerCLIE2ETests -{ - using System.Collections; - using System.IO; - using Newtonsoft.Json; - using Newtonsoft.Json.Linq; - - /// <summary> - /// Helper class to set winget settings. - /// </summary> - internal static class WinGetSettingsHelper - { - /// <summary> - /// Gets the user settings path by calling winget settings export. - /// </summary> - /// <returns>Expanded path for user settings.</returns> - public static string GetUserSettingsPath() - { - var result = TestCommon.RunAICLICommand("settings", "export"); - var output = result.StdOut; - var serialized = JObject.Parse(output); - return (string)serialized.GetValue("userSettingsFile"); - } - - /// <summary> - /// Initialize settings. - /// </summary> - public static void InitializeWingetSettings() - { - var settingsJson = new Hashtable() - { - { - "experimentalFeatures", - new Hashtable() - { - { "experimentalArg", false }, - { "experimentalCmd", false }, - { "dependencies", false }, - { "directMSI", false }, - { "download", false }, - } - }, - { - "debugging", - new Hashtable() - { - { "enableSelfInitiatedMinidump", true }, - { "keepAllLogFiles", true }, - } - }, - { - "installBehavior", - new Hashtable() - { - } - }, - }; - - // Run winget one time to initialize settings directory - // when running in unpackaged context - TestCommon.RunAICLICommand(string.Empty, "-v"); - - SetWingetSettings(settingsJson); - } - - /// <summary> - /// Converts a hashtable to json and writes to the settings file. - /// </summary> - /// <param name="settingsJson">Settings to set.</param> - public static void SetWingetSettings(Hashtable settingsJson) - { - SetWingetSettings(JsonConvert.SerializeObject(settingsJson, Formatting.Indented)); - } - - /// <summary> - /// Writes string to settings file. - /// </summary> - /// <param name="settings">Settings as string.</param> - public static void SetWingetSettings(string settings) - { - File.WriteAllText(TestCommon.SettingsJsonFilePath, settings); - } - - /// <summary> - /// Configure experimental features. - /// </summary> - /// <param name="featureName">Feature name.</param> - /// <param name="status">Status.</param> - public static void ConfigureFeature(string featureName, bool status) - { - JObject settingsJson = JObject.Parse(File.ReadAllText(TestCommon.SettingsJsonFilePath)); - - if (!settingsJson.ContainsKey("experimentalFeatures")) - { - settingsJson["experimentalFeatures"] = new JObject(); - } - - var experimentalFeatures = settingsJson["experimentalFeatures"]; - experimentalFeatures[featureName] = status; - - File.WriteAllText(TestCommon.SettingsJsonFilePath, settingsJson.ToString()); - } - - /// <summary> - /// Configure the install behavior. - /// </summary> - /// <param name="settingName">Setting name.</param> - /// <param name="value">Setting value.</param> - public static void ConfigureInstallBehavior(string settingName, string value) - { - JObject settingsJson = JObject.Parse(File.ReadAllText(TestCommon.SettingsJsonFilePath)); - - if (!settingsJson.ContainsKey("installBehavior")) - { - settingsJson["installBehavior"] = new JObject(); - } - - var installBehavior = settingsJson["installBehavior"]; - installBehavior[settingName] = value; - - File.WriteAllText(TestCommon.SettingsJsonFilePath, settingsJson.ToString()); - } - - /// <summary> - /// Configure the install behavior preferences. - /// </summary> - /// <param name="settingName">Setting name.</param> - /// <param name="value">Setting value.</param> - public static void ConfigureInstallBehaviorPreferences(string settingName, string value) - { - JObject settingsJson = JObject.Parse(File.ReadAllText(TestCommon.SettingsJsonFilePath)); - - if (!settingsJson.ContainsKey("installBehavior")) - { - settingsJson["installBehavior"] = new JObject(); - } - - var installBehavior = settingsJson["installBehavior"]; - - if (installBehavior["preferences"] == null) - { - installBehavior["preferences"] = new JObject(); - } - - var preferences = installBehavior["preferences"]; - preferences[settingName] = value; - - File.WriteAllText(TestCommon.SettingsJsonFilePath, settingsJson.ToString()); - } - - /// <summary> - /// Configure the install behavior requirements. - /// </summary> - /// <param name="settingName">Setting name.</param> - /// <param name="value">Setting value.</param> - public static void ConfigureInstallBehaviorRequirements(string settingName, string value) - { - JObject settingsJson = JObject.Parse(File.ReadAllText(TestCommon.SettingsJsonFilePath)); - - if (!settingsJson.ContainsKey("installBehavior")) - { - settingsJson["installBehavior"] = new JObject(); - } - - var installBehavior = settingsJson["installBehavior"]; - - if (installBehavior["requirements"] == null) - { - installBehavior["requirements"] = new JObject(); - } - - var requirements = installBehavior["requirements"]; - requirements[settingName] = value; - - File.WriteAllText(TestCommon.SettingsJsonFilePath, settingsJson.ToString()); - } - - /// <summary> - /// Initialize all features. - /// </summary> - /// <param name="status">Initialized feature value.</param> - public static void InitializeAllFeatures(bool status) - { - ConfigureFeature("experimentalArg", status); - ConfigureFeature("experimentalCmd", status); - ConfigureFeature("dependencies", status); - ConfigureFeature("directMSI", status); - ConfigureFeature("pinning", status); - ConfigureFeature("configuration", status); - ConfigureFeature("windowsFeature", status); - ConfigureFeature("download", status); - } - } -} diff --git a/src/AppInstallerCLIE2ETests/WinGetUtil/WinGetUtilDownload.cs b/src/AppInstallerCLIE2ETests/WinGetUtil/WinGetUtilDownload.cs @@ -7,7 +7,8 @@ namespace AppInstallerCLIE2ETests.WinGetUtil { using System.IO; - using System.Linq; + using System.Linq; + using AppInstallerCLIE2ETests.Helpers; using NUnit.Framework; /// <summary> diff --git a/src/AppInstallerCLIE2ETests/WinGetUtil/WinGetUtilInstallerMetadataCollection.cs b/src/AppInstallerCLIE2ETests/WinGetUtil/WinGetUtilInstallerMetadataCollection.cs @@ -8,7 +8,8 @@ namespace AppInstallerCLIE2ETests.WinGetUtil { using System; using System.IO; - using System.Runtime.InteropServices; + using System.Runtime.InteropServices; + using AppInstallerCLIE2ETests.Helpers; using Newtonsoft.Json; using NUnit.Framework; diff --git a/src/AppInstallerCLIE2ETests/WinGetUtil/WinGetUtilLog.cs b/src/AppInstallerCLIE2ETests/WinGetUtil/WinGetUtilLog.cs @@ -6,7 +6,8 @@ namespace AppInstallerCLIE2ETests.WinGetUtil { - using System.IO; + using System.IO; + using AppInstallerCLIE2ETests.Helpers; using NUnit.Framework; /// <summary> diff --git a/src/AppInstallerCLIE2ETests/WinGetUtil/WinGetUtilManifest.cs b/src/AppInstallerCLIE2ETests/WinGetUtil/WinGetUtilManifest.cs @@ -7,7 +7,8 @@ namespace AppInstallerCLIE2ETests.WinGetUtil { using System; - using System.IO; + using System.IO; + using AppInstallerCLIE2ETests.Helpers; using NUnit.Framework; /// <summary> diff --git a/src/AppInstallerCLIE2ETests/WinGetUtil/WinGetUtilSQLiteIndex.cs b/src/AppInstallerCLIE2ETests/WinGetUtil/WinGetUtilSQLiteIndex.cs @@ -8,7 +8,8 @@ namespace AppInstallerCLIE2ETests.WinGetUtil { using System; using System.IO; - using System.Runtime.InteropServices; + using System.Runtime.InteropServices; + using AppInstallerCLIE2ETests.Helpers; using NUnit.Framework; /// <summary> diff --git a/src/IndexCreationTool/IndexCreationTool.csproj b/src/IndexCreationTool/IndexCreationTool.csproj @@ -7,4 +7,18 @@ <Platforms>x64;x86</Platforms> </PropertyGroup> + <ItemGroup> + <ProjectReference Include="..\WinGetSourceCreator\WinGetSourceCreator.csproj" /> + <ProjectReference Include="..\WinGetUtilInterop\WinGetUtilInterop.csproj" /> + <ProjectReference Include="..\WinGetUtil\WinGetUtil.vcxproj" > + <OutputItemType>Content</OutputItemType> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + <ReferenceOutputAssembly>True</ReferenceOutputAssembly> + </ProjectReference> + </ItemGroup> + + <Target Name="PostBuild" AfterTargets="PostBuildEvent"> + <Copy SourceFiles="$(TargetDir)..\WinGetUtil\WinGetUtil.dll" DestinationFiles="$(TargetDir)WinGetUtil.dll" /> + </Target> + </Project> diff --git a/src/IndexCreationTool/Program.cs b/src/IndexCreationTool/Program.cs @@ -3,127 +3,49 @@ namespace IndexCreationTool { + using Microsoft.WinGetSourceCreator; using System; - using System.Collections.Generic; - using System.Diagnostics; using System.IO; - using System.Linq; + using System.Text.Json; + using System.Text.Json.Serialization; + using WinGetSourceCreator.Model; class Program { - public const string IndexName = @"index.db"; - public const string IndexPathInPackage = @"Public\index.db"; - public const string IndexPackageName = @"source.msix"; - - static void Main(string[] args) + private static void PrintUsage() { - string rootDir = string.Empty; - string appxManifestPath = string.Empty; - string certPath = string.Empty; - - // List of directories to include. By default, include all directories. - List<string> includeDirList = new() { string.Empty }; - - for (int i = 0; i < args.Length; i++) - { - if (args[i] == "-d" && ++i < args.Length) - { - rootDir = args[i]; - } - else if (args[i] == "-i" && ++i < args.Length) - { - includeDirList = args[i].Split(",").ToList(); - } - else if (args[i] == "-m" && ++i < args.Length) - { - appxManifestPath = args[i]; - } - else if (args[i] == "-c" && ++i < args.Length) - { - certPath = args[i]; - } - } - - if (string.IsNullOrEmpty(rootDir)) - { - Console.WriteLine("Usage: IndexCreationTool.exe -d <Path to search for yaml> [-i <Comma-separated list of included dirs in the search for yaml. If not specified, include all dirs>] [-m <appxmanifest for index package> [-c <cert for signing index package>]]"); - return; - } + Console.WriteLine("Usage: IndexCreationTool.exe -f <local source input json file>"); + } + static int Main(string[] args) + { try { - if (File.Exists(IndexName)) + string inputFile = string.Empty; + for (int i = 0; i < args.Length; i++) { - File.Delete(IndexName); - } - - using (var indexHelper = WinGetUtilWrapper.Create(IndexName)) - { - foreach (string includeDir in includeDirList) + if (args[i] == "-f" && ++i < args.Length) { - var fullPath = Path.Combine(rootDir, includeDir); - Queue<string> filesQueue = new(Directory.EnumerateFiles(fullPath, "*.yaml", SearchOption.AllDirectories)); - - while (filesQueue.Count > 0) - { - int currentCount = filesQueue.Count; - - for (int i = 0; i < currentCount; i++) - { - string file = filesQueue.Dequeue(); - try - { - indexHelper.AddManifest(file, Path.GetRelativePath(rootDir, file)); - } - catch - { - // If adding manifest to index fails, add to queue and try again. - // This can occur if there is a package dependency that has not yet been added to the index. - filesQueue.Enqueue(file); - } - } - - if (filesQueue.Count == currentCount) - { - Console.WriteLine("Failed to add all manifests in directory to index."); - Environment.Exit(-1); - } - } + inputFile = args[i]; } - - indexHelper.PrepareForPackaging(); } - if (!string.IsNullOrEmpty(appxManifestPath)) + if (string.IsNullOrEmpty(inputFile) || !File.Exists(inputFile)) { - using (StreamWriter outputFile = new StreamWriter("MappingFile.txt")) - { - outputFile.WriteLine("[Files]"); - outputFile.WriteLine($"\"{IndexName}\" \"{IndexPathInPackage}\""); - outputFile.WriteLine($"\"{appxManifestPath}\" \"AppxManifest.xml\""); - } - RunCommand("makeappx.exe", $"pack /f MappingFile.txt /o /nv /p {IndexPackageName}"); - - if (!string.IsNullOrEmpty(certPath)) - { - RunCommand("signtool.exe", $"sign /a /fd sha256 /f {certPath} {IndexPackageName}"); - } + PrintUsage(); + throw new ArgumentException("Missing input file"); } + + WinGetLocalSource.CreateFromLocalSourceFile(inputFile); } catch (Exception e) { - Console.WriteLine("Failed. Reason: " + e.Message); + PrintUsage(); + Console.WriteLine(e.Message); + return -1; } - Environment.Exit(0); - } - - static void RunCommand(string command, string args) - { - Process p = new Process(); - p.StartInfo = new ProcessStartInfo(command, args); - p.Start(); - p.WaitForExit(); + return 0; } } -}- \ No newline at end of file +} diff --git a/src/IndexCreationTool/WinGetUtilWrapper.cs b/src/IndexCreationTool/WinGetUtilWrapper.cs @@ -1,264 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace IndexCreationTool -{ - using System; - using System.Runtime.InteropServices; - - /// <summary> - /// Wrapper class around WinGetUtil index modifier native implementation. - /// </summary> - internal class WinGetUtilWrapper : IDisposable - { - /// <summary> - /// Dll name. - /// </summary> - public const string DllName = @"WinGetUtil.dll"; - - private const uint LatestVersion = unchecked((uint)-1); - - private IntPtr indexHandle; - - /// <summary> - /// Initializes a new instance of the <see cref="WinGetUtilWrapper"/> class. - /// </summary> - /// <param name="indexHandle">Handle of the index.</param> - private WinGetUtilWrapper(IntPtr indexHandle) - { - this.indexHandle = indexHandle; - } - - /// <summary> - /// Creates a new index file in the specified path. - /// </summary> - /// <param name="indexFile">Index file to create.</param> - /// <returns>Instance of WinGetUtilWrapper.</returns> - public static WinGetUtilWrapper Create(string indexFile) - { - try - { - WinGetSQLiteIndexCreate( - indexFile, - WinGetUtilWrapper.LatestVersion, - WinGetUtilWrapper.LatestVersion, - out IntPtr index); - return new WinGetUtilWrapper(index); - } - catch (Exception e) - { - Console.WriteLine($"Error to create {indexFile}. {Environment.NewLine}{e.ToString()}"); - throw; - } - } - - /// <summary> - /// Open the index. - /// </summary> - /// <param name="indexFile">Index file to open.</param> - /// <returns>Instance of WinGetUtilWrapper.</returns> - public static WinGetUtilWrapper Open(string indexFile) - { - try - { - WinGetSQLiteIndexOpen(indexFile, out IntPtr index); - return new WinGetUtilWrapper(index); - } - catch (Exception e) - { - Console.WriteLine($"Error to open {indexFile}. {Environment.NewLine}{e.ToString()}"); - throw; - } - } - - /// <summary> - /// Adds manifest to index. - /// </summary> - /// <param name="manifestPath">Manifest to add.</param> - /// <param name="relativePath">Path of the manifest in the repository.</param> - public void AddManifest(string manifestPath, string relativePath) - { - try - { - Console.WriteLine($"Adding manifest {manifestPath} on index file."); - WinGetSQLiteIndexAddManifest(this.indexHandle, manifestPath, relativePath); - return; - } - catch (Exception e) - { - Console.WriteLine($"Error to add manifest {manifestPath} with relative path {relativePath}. {Environment.NewLine}{e.ToString()}"); - throw; - } - } - - /// <summary> - /// Updates manifest in the index. - /// </summary> - /// <param name="manifestPath">Path to manifest to modify.</param> - /// <param name="relativePath">Path of the manifest in the repository.</param> - /// <returns>True if index was modified.</returns> - public bool UpdateManifest(string manifestPath, string relativePath) - { - try - { - Console.WriteLine($"Updating manifest {manifestPath} on index file."); - - // For now, modifying a manifest implies that the file didn't got moved in the repository. So only - // contents of the file are modified. However, in the future we might support moving which requires - // oldManifestPath, oldRelativePath, newManifestPath and oldManifestPath. - WinGetSQLiteIndexUpdateManifest( - this.indexHandle, - manifestPath, - relativePath, - out bool indexModified); - - if (!indexModified) - { - // This means that some of the attributes that don't get indexed, like Description, where - // were modified, so the index doesn't got updated. - Console.WriteLine($"Manifest {manifestPath} didn't result in a modification to the index."); - } - - return indexModified; - } - catch (Exception e) - { - Console.WriteLine($"Error to update manifest {manifestPath} with relative path {relativePath}. {Environment.NewLine}{e.ToString()}"); - throw; - } - } - - /// <summary> - /// Delete manifest from index. - /// </summary> - /// <param name="manifestPath">Path to manifest to modify.</param> - /// <param name="relativePath">Path of the manifest in the repository.</param> - public void RemoveManifest(string manifestPath, string relativePath) - { - try - { - WinGetSQLiteIndexRemoveManifest(this.indexHandle, manifestPath, relativePath); - return; - } - catch (Exception e) - { - Console.WriteLine($"Error to remove manifest {manifestPath} with relative path {relativePath}. {Environment.NewLine}{e.ToString()}"); - throw; - } - } - - /// <summary> - /// Wrapper for WinGetSQLiteIndexPrepareForPackaging. - /// </summary> - public void PrepareForPackaging() - { - try - { - WinGetSQLiteIndexPrepareForPackaging(this.indexHandle); - return; - } - catch (Exception e) - { - Console.WriteLine($"Error to prepare for packaging. {Environment.NewLine}{e.ToString()}"); - throw; - } - } - - /// <summary> - /// Dispose method. - /// </summary> - public void Dispose() - { - this.Dispose(true); - GC.SuppressFinalize(this); - } - - /// <summary> - /// Dispose method to dispose the Git desktop process runner. - /// </summary> - /// <param name="disposing">Bool value indicating if Dispose is being run.</param> - protected void Dispose(bool disposing) - { - if (disposing) - { - if (this.indexHandle != IntPtr.Zero) - { - WinGetSQLiteIndexClose(this.indexHandle); - } - } - } - - /// <summary> - /// Creates a new index file at filePath with the given version. - /// </summary> - /// <param name="filePath">File path to create index.</param> - /// <param name="majorVersion">Major version.</param> - /// <param name="minorVersion">Minor version.</param> - /// <param name="index">Out handle of the index.</param> - /// <returns>HRESULT.</returns> - [DllImport(DllName, CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode, PreserveSig = false)] - private static extern IntPtr WinGetSQLiteIndexCreate(string filePath, uint majorVersion, uint minorVersion, out IntPtr index); - - /// <summary> - /// Opens an existing index at filePath. - /// </summary> - /// <param name="filePath">File path of index.</param> - /// <param name="index">Out handle of the index.</param> - /// <returns>HRESULT.</returns> - [DllImport(DllName, CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode, PreserveSig = false)] - private static extern IntPtr WinGetSQLiteIndexOpen(string filePath, out IntPtr index); - - /// <summary> - /// Closes the index. - /// </summary> - /// <param name="index">Handle of the index.</param> - /// <returns>HRESULT.</returns> - [DllImport(DllName, CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode, PreserveSig = false)] - private static extern IntPtr WinGetSQLiteIndexClose(IntPtr index); - - /// <summary> - /// Adds the manifest at the repository relative path to the index. - /// If the function succeeds, the manifest has been added. - /// </summary> - /// <param name="index">Handle of the index.</param> - /// <param name="manifestPath">Manifest to add.</param> - /// <param name="relativePath">Path of the manifest in the container.</param> - /// <returns>HRESULT.</returns> - [DllImport(DllName, CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode, PreserveSig = false)] - private static extern IntPtr WinGetSQLiteIndexAddManifest(IntPtr index, string manifestPath, string relativePath); - - /// <summary> - /// Updates the manifest at the repository relative path in the index. - /// The out value indicates whether the index was modified by the function. - /// </summary> - /// <param name="index">Handle of the index.</param> - /// <param name="manifestPath">Old manifest path.</param> - /// <param name="relativePath">Old relative path in the container.</param> - /// <param name="indexModified">Out bool if the index is modified.</param> - /// <returns>HRESULT.</returns> - [DllImport(DllName, CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode, PreserveSig = false)] - private static extern IntPtr WinGetSQLiteIndexUpdateManifest( - IntPtr index, - string manifestPath, - string relativePath, - [MarshalAs(UnmanagedType.U1)] out bool indexModified); - - /// <summary> - /// Removes the manifest at the repository relative path from the index. - /// </summary> - /// <param name="index">Index handle.</param> - /// <param name="manifestPath">Manifest path to remove.</param> - /// <param name="relativePath">Relative path in the container.</param> - /// <returns>HRESULT.</returns> - [DllImport(DllName, CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode, PreserveSig = false)] - private static extern IntPtr WinGetSQLiteIndexRemoveManifest(IntPtr index, string manifestPath, string relativePath); - - /// <summary> - /// Removes data that is no longer needed for an index that is to be published. - /// </summary> - /// <param name="index">Index handle.</param> - /// <returns>HRESULT.</returns> - [DllImport(DllName, CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode, PreserveSig = false)] - private static extern IntPtr WinGetSQLiteIndexPrepareForPackaging(IntPtr index); - } -}- \ No newline at end of file diff --git a/src/LocalhostWebServer/LocalhostWebServer.csproj b/src/LocalhostWebServer/LocalhostWebServer.csproj @@ -2,8 +2,22 @@ <PropertyGroup> <TargetFramework>net6.0</TargetFramework> - <OutDir>$(SolutionDir)$(Platform)\$(Configuration)\LocalhostWebServer\</OutDir> - <Platforms>x64;x86</Platforms> + <OutDir>$(SolutionDir)$(Platform)\$(Configuration)\LocalhostWebServer\</OutDir> + <Platforms>x64;x86</Platforms> </PropertyGroup> + <ItemGroup> + <ProjectReference Include="..\WinGetSourceCreator\WinGetSourceCreator.csproj" /> + <ProjectReference Include="..\WinGetUtilInterop\WinGetUtilInterop.csproj" /> + <ProjectReference Include="..\WinGetUtil\WinGetUtil.vcxproj" > + <OutputItemType>Content</OutputItemType> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + <ReferenceOutputAssembly>True</ReferenceOutputAssembly> + </ProjectReference> + </ItemGroup> + + <Target Name="PostBuild" AfterTargets="PostBuildEvent"> + <Copy SourceFiles="$(TargetDir)..\WinGetUtil\WinGetUtil.dll" DestinationFiles="$(TargetDir)WinGetUtil.dll" /> + </Target> + </Project> diff --git a/src/LocalhostWebServer/Program.cs b/src/LocalhostWebServer/Program.cs @@ -10,6 +10,10 @@ namespace LocalhostWebServer using System.IO; using Microsoft.Extensions.Configuration; using System.Security.Cryptography.X509Certificates; + using System.Text.Json.Serialization; + using System.Text.Json; + using WinGetSourceCreator.Model; + using Microsoft.WinGetSourceCreator; public class Program { @@ -22,8 +26,9 @@ namespace LocalhostWebServer Startup.StaticFileRoot = config.GetValue<string>("StaticFileRoot"); Startup.CertPath = config.GetValue<string>("CertPath"); Startup.CertPassword = config.GetValue<string>("CertPassword"); - Startup.PutCertInRoot = config.GetValue<bool>("PutCertInRoot", false); Startup.Port = config.GetValue<Int32>("Port", 5001); + Startup.OutCertFile = config.GetValue<string>("OutCertFile"); + Startup.LocalSourceJson = config.GetValue<string>("LocalSourceJson"); if (string.IsNullOrEmpty(Startup.StaticFileRoot) || string.IsNullOrEmpty(Startup.CertPath)) @@ -35,10 +40,26 @@ namespace LocalhostWebServer Directory.CreateDirectory(Startup.StaticFileRoot); - if (Startup.PutCertInRoot) + if (!string.IsNullOrEmpty(Startup.OutCertFile)) { + string parent = Path.GetDirectoryName(Startup.OutCertFile); + if (!string.IsNullOrEmpty(parent)) + { + Directory.CreateDirectory(parent); + } + X509Certificate2 serverCertificate = new X509Certificate2(Startup.CertPath, Startup.CertPassword, X509KeyStorageFlags.EphemeralKeySet); - File.WriteAllBytes(Path.Combine(Startup.StaticFileRoot, "servercert.cer"), serverCertificate.Export(X509ContentType.Cert)); + File.WriteAllBytes(Startup.OutCertFile, serverCertificate.Export(X509ContentType.Cert)); + } + + if (!string.IsNullOrEmpty(Startup.LocalSourceJson)) + { + if (!File.Exists(Startup.LocalSourceJson)) + { + throw new FileNotFoundException(Startup.LocalSourceJson); + } + + WinGetLocalSource.CreateFromLocalSourceFile(Startup.LocalSourceJson); } CreateHostBuilder(args).Build().Run(); diff --git a/src/LocalhostWebServer/Run-LocalhostWebServer.ps1 b/src/LocalhostWebServer/Run-LocalhostWebServer.ps1 @@ -9,6 +9,10 @@ Path to HTTPS Development Certificate File (pfx) .PARAMETER CertPassword Secure Password for HTTPS Certificate +.PARAMETER OutCertFile + Export cert location. +.PARAMETER LocalSourceJson + Local source json definition #> param( @@ -22,10 +26,15 @@ param( [string]$CertPath, [Parameter(Mandatory=$true)] - [string]$CertPassword + [string]$CertPassword, + + [Parameter()] + [string]$OutCertFile, + + [Parameter()] + [string]$LocalSourceJson ) cd $BuildRoot -Start-Process -FilePath "LocalhostWebServer.exe" -ArgumentList "StaticFileRoot=$StaticFileRoot CertPath=$CertPath CertPassword=$CertPassword PutCertInRoot=True" - +Start-Process -FilePath "LocalhostWebServer.exe" -ArgumentList "StaticFileRoot=$StaticFileRoot CertPath=$CertPath CertPassword=$CertPassword OutCertFile=$OutCertFile LocalSourceJson=$LocalSourceJson" diff --git a/src/LocalhostWebServer/Startup.cs b/src/LocalhostWebServer/Startup.cs @@ -21,10 +21,12 @@ namespace LocalhostWebServer public static string CertPassword { get; set; } - public static bool PutCertInRoot { get; set; } + public static string OutCertFile { get; set; } public static int Port { get; set; } + public static string LocalSourceJson { get; set; } + public Startup(IConfiguration configuration) { Configuration = configuration; diff --git a/src/WinGetSourceCreator/Helpers.cs b/src/WinGetSourceCreator/Helpers.cs @@ -0,0 +1,208 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.WinGetSourceCreator +{ + using global::WinGetSourceCreator.Model; + using Microsoft.Msix.Utils.ProcessRunner; + using System.Diagnostics; + using System.Xml; + + internal static class Helpers + { + public static void SignInstaller(SourceInstaller installer, Signature signature) + { + if (installer.Type == InstallerType.Msix) + { + SignMsixFile(installer.InstallerFile, signature); + } + else + { + SignFile(installer.InstallerFile, signature); + } + } + + public static void SignFile(string fileToSign, Signature signature) + { + if (!File.Exists(fileToSign)) + { + throw new FileNotFoundException(fileToSign); + } + + if (!File.Exists(signature.CertFile)) + { + throw new FileNotFoundException(signature.CertFile); + } + + string pathToSDK = SDKDetector.Instance.LatestSDKBinPath; + string signtoolExecutable = Path.Combine(pathToSDK, "signtool.exe"); + string command = $"sign /a /fd sha256 /f {signature.CertFile} "; + if (!string.IsNullOrEmpty(signature.Password)) + { + command += $"/p {signature.Password} "; + } + command += fileToSign; + RunCommand(signtoolExecutable, command); + } + + public static void SignMsixFile(string fileToSign, Signature signature) + { + if (!File.Exists(fileToSign)) + { + throw new FileNotFoundException(fileToSign); + } + + // Modify publisher if needed. + if (signature.Publisher != null) + { + string tmpPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Unpack(fileToSign, tmpPath); + ModifyAppxManifestIdentity(Path.Combine(tmpPath, "AppxManifest.xml"), signature.Publisher); + Pack(fileToSign, tmpPath); + + try + { + Directory.Delete(tmpPath, true); + } + catch (Exception) + { + } + } + + SignFile(fileToSign, signature); + } + + public static void Unpack(string package, string outDir) + { + if (!File.Exists(package)) + { + throw new FileNotFoundException(package); + } + + string pathToSDK = SDKDetector.Instance.LatestSDKBinPath; + string makeappxExecutable = Path.Combine(pathToSDK, "makeappx.exe"); + string args = $"unpack /nv /p {package} /d {outDir}"; + Process p = new Process + { + StartInfo = new ProcessStartInfo(makeappxExecutable, args) + }; + p.Start(); + p.WaitForExit(); + } + + public static void PackWithMappingFile(string outputPackage, string mappingFile) + { + if (!File.Exists(mappingFile)) + { + throw new FileNotFoundException(mappingFile); + } + + string pathToSDK = SDKDetector.Instance.LatestSDKBinPath; + string makeappxExecutable = Path.Combine(pathToSDK, "makeappx.exe"); + string args = $"pack /o /nv /f {mappingFile} /p {outputPackage}"; + RunCommand(makeappxExecutable, args); + } + + public static void Pack(string outputPackage, string directoryToPack) + { + if (!Directory.Exists(directoryToPack)) + { + throw new DirectoryNotFoundException(directoryToPack); + } + + if (File.Exists(outputPackage)) + { + File.Delete(outputPackage); + } + + string pathToSDK = SDKDetector.Instance.LatestSDKBinPath; + string makeappxExecutable = Path.Combine(pathToSDK, "makeappx.exe"); + string args = $"pack /o /d {directoryToPack} /p {outputPackage}"; + RunCommand(makeappxExecutable, args); + } + + public static void RunCommand(string command, string args, string? workingDirectory = null) + { + Process p = new() + { + StartInfo = new ProcessStartInfo(command, args) + }; + + if (workingDirectory != null) + { + p.StartInfo.WorkingDirectory = workingDirectory; + } + p.Start(); + p.WaitForExit(); + } + + // If in the future we edit more elements, this should be a nice wrapper class. + public static void ModifyAppxManifestIdentity(string manifestFile, string? identityPublisher) + { + if (!File.Exists(manifestFile)) + { + throw new FileNotFoundException(manifestFile); + } + + var xmlDoc = new XmlDocument(); + XmlNamespaceManager namespaces = new XmlNamespaceManager(xmlDoc.NameTable); + namespaces.AddNamespace("n", "http://schemas.microsoft.com/appx/manifest/foundation/windows10"); + xmlDoc.Load(manifestFile); + var identityNode = xmlDoc.SelectSingleNode("/n:Package/n:Identity", namespaces); + if (identityNode == null) + { + throw new NullReferenceException("Identity node"); + } + + if (!string.IsNullOrEmpty(identityPublisher)) + { + var attr = identityNode.Attributes?["Publisher"]; + if (attr == null) + { + throw new NullReferenceException("Publisher attribute"); + } + attr.Value = identityPublisher; + } + + xmlDoc.Save(manifestFile); + } + + // Gets the AppxSignature.p7x file. + public static string GetSignatureFileFromMsix(string packageFilePath) + { + string extractedPackageDest = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + if (Directory.Exists(extractedPackageDest)) + { + Directory.Delete(extractedPackageDest, true); + } + + Helpers.Unpack(packageFilePath, extractedPackageDest); + + return Path.Combine(extractedPackageDest, "AppxSignature.p7x"); + } + + public static void CopyDirectory(string sourceDirName, string destDirName) + { + if (!Directory.Exists(destDirName)) + { + Directory.CreateDirectory(destDirName); + } + + DirectoryInfo dir = new (sourceDirName); + DirectoryInfo[] dirs = dir.GetDirectories(); + + FileInfo[] files = dir.GetFiles(); + foreach (FileInfo file in files) + { + string temppath = Path.Combine(destDirName, file.Name); + file.CopyTo(temppath, false); + } + + foreach (DirectoryInfo subdir in dirs) + { + string temppath = Path.Combine(destDirName, subdir.Name); + CopyDirectory(subdir.FullName, temppath); + } + } + } +} diff --git a/src/WinGetSourceCreator/ManifestTokens.cs b/src/WinGetSourceCreator/ManifestTokens.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.WinGetSourceCreator +{ + using System.Security.Cryptography; + + public class ManifestTokens + { + public ManifestTokens() + { + } + + public Dictionary<string, string> Tokens { get; private set; } = new Dictionary<string, string>(); + + public void AddHashToken(string file, string token) + { + if (!token.StartsWith("<") || !token.EndsWith(">")) + { + throw new Exception("Token should be in the form of <TOKEN VALUE>"); + } + + var hash = HashFile(file); + this.Tokens.Add(token, hash); + } + + /// <summary> + /// Gets the hash of the specified file. + /// </summary> + /// <param name="filePath">File path.</param> + /// <returns>Hash of file.</returns> + private static string HashFile(string filePath) + { + if (!File.Exists(filePath)) + { + throw new FileNotFoundException(filePath); + } + + string hash = string.Empty; + + using SHA256 mySHA256 = SHA256.Create(); + using FileStream fs = File.OpenRead(filePath); + fs.Position = 0; + byte[] hashValue = mySHA256.ComputeHash(fs); + + for (int i = 0; i < hashValue.Length; i++) + { + hash += $"{hashValue[i]:X2}"; + } + + return hash; + } + } +} diff --git a/src/WinGetSourceCreator/Model/DynamicInstaller.cs b/src/WinGetSourceCreator/Model/DynamicInstaller.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace WinGetSourceCreator.Model +{ + using Microsoft.WinGetSourceCreator; + using System.IO.Compression; + + public class DynamicInstaller : Installer + { + // Input depends on the Type. + // For zip it is the directories or files that need to included in the zip + public List<string> Input { get; set; } = new List<string>(); + + internal new void Validate() + { + base.Validate(); + } + + public string Create(string workingDirectory) + { + string outputFile = this.Name; + if (!Path.IsPathFullyQualified(outputFile)) + { + outputFile = Path.Combine(workingDirectory, outputFile); + } + + var parent = Path.GetDirectoryName(outputFile); + if (!string.IsNullOrEmpty(parent)) + { + Directory.CreateDirectory(parent); + } + + if (this.Type == InstallerType.Zip) + { + CreateZipInstaller(outputFile); + } + else + { + throw new NotImplementedException(); + } + + return outputFile; + } + + private void CreateZipInstaller(string outputFile) + { + var tmpPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + if (Directory.Exists(tmpPath)) + { + Directory.Delete(tmpPath, true); + } + Directory.CreateDirectory(tmpPath); + + foreach (var input in this.Input) + { + if (!Path.IsPathFullyQualified(input)) + { + throw new InvalidOperationException($"Must be a fully qualified name {input}"); + } + + if (File.Exists(input)) + { + // TODO: maybe we want to preserve the dir? + File.Copy(input, Path.Combine(tmpPath, Path.GetFileName(input)), true); + } + else if (Directory.Exists(input)) + { + Helpers.CopyDirectory(input, tmpPath); + } + else + { + throw new InvalidOperationException(input); + } + } + + ZipFile.CreateFromDirectory(tmpPath, outputFile); + + try + { + Directory.Delete(tmpPath, true); + } + catch (Exception) + { + } + } + } +} diff --git a/src/WinGetSourceCreator/Model/Installer.cs b/src/WinGetSourceCreator/Model/Installer.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace WinGetSourceCreator.Model +{ + public abstract class Installer + { + public InstallerType Type { get; set; } + + // The name of the installer when it copied or created. + public string Name { get; set; } = string.Empty; + + // The identifying token of the installer in the manifests. + public string? HashToken { get; set; } + + // An optional token relevant to the installer. + // If the installer is an msix, this is the token used for the appx signature hash. + public string? SignatureToken { get; set; } + + public Signature? Signature { get; set; } + + public bool SkipSignature { get; set; } + + protected void Validate() + { + if (string.IsNullOrEmpty(this.Name)) + { + throw new ArgumentNullException(nameof(this.Name)); + } + + if (this.Signature != null) + { + this.Signature.Validate(); + } + + if (this.Type != InstallerType.Msix && !string.IsNullOrEmpty(this.SignatureToken)) + { + throw new Exception($"{nameof(this.SignatureToken)} can only be used for MSIX"); + } + } + } +} diff --git a/src/WinGetSourceCreator/Model/InstallerType.cs b/src/WinGetSourceCreator/Model/InstallerType.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace WinGetSourceCreator.Model +{ + public enum InstallerType + { + Msix, + Exe, + Msi, + Zip, + } +} diff --git a/src/WinGetSourceCreator/Model/LocalInstaller.cs b/src/WinGetSourceCreator/Model/LocalInstaller.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace WinGetSourceCreator.Model +{ + public class LocalInstaller : Installer + { + // The full path of the installer. + // Gets copied to the output directory and optionally signed. + public string Input { get; set; } = string.Empty; + + internal new void Validate() + { + base.Validate(); + if (string.IsNullOrEmpty(this.Input)) + { + throw new ArgumentNullException(nameof(this.Input)); + } + + if (!File.Exists(this.Input)) + { + throw new FileNotFoundException(this.Input); + } + } + } +} diff --git a/src/WinGetSourceCreator/Model/LocalSource.cs b/src/WinGetSourceCreator/Model/LocalSource.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace WinGetSourceCreator.Model +{ + public class LocalSource + { + // Full path of the input appx manifest. + // Will be used to generate the package. + public string AppxManifest { get; set; } = string.Empty; + + // The working directory where manifest will copied and referenced by the index. + public string WorkingDirectory { get; set; } = string.Empty; + + // Input manifests. + // If it is a file the manifest gets copied to the input directory. + // If it is a directory the manifests will be copied preserving the sub dirs structure. + public List<string> LocalManifests { get; set; } = new(); + + public List<LocalInstaller>? LocalInstallers { get; set; } + + public List<DynamicInstaller>? DynamicInstallers { get; set; } + + public Signature? Signature { get; set; } + + public void Validate() + { + if (string.IsNullOrEmpty(this.AppxManifest)) + { + throw new ArgumentNullException(nameof(this.AppxManifest)); + } + + if (string.IsNullOrEmpty(this.WorkingDirectory)) + { + throw new ArgumentNullException(nameof(this.WorkingDirectory)); + } + + if (this.LocalManifests.Count == 0) + { + throw new ArgumentException(nameof(this.LocalManifests)); + } + + if (this.LocalInstallers != null) + { + foreach (var installer in this.LocalInstallers) + { + installer.Validate(); + } + } + + if (this.DynamicInstallers != null) + { + foreach (var installer in this.DynamicInstallers) + { + installer.Validate(); + } + } + + if (this.Signature != null) + { + this.Signature.Validate(); + } + } + + public string GetIndexName() + { + return $"index.db"; + } + + public string GetSourceName() + { + return "source.msix"; + } + } +} diff --git a/src/WinGetSourceCreator/Model/Signature.cs b/src/WinGetSourceCreator/Model/Signature.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace WinGetSourceCreator.Model +{ + public class Signature + { + // Full path of the certificate used to sign the package and installers + public string CertFile { get; set; } = string.Empty; + + public string? Password { get; set; } + + // The publisher for the AppxPackage Identity Name property. + public string? Publisher { get; set; } + + internal void Validate() + { + if (string.IsNullOrEmpty(this.CertFile)) + { + throw new ArgumentNullException(nameof(this.CertFile)); + } + } + } +} diff --git a/src/WinGetSourceCreator/Model/SourceInstaller.cs b/src/WinGetSourceCreator/Model/SourceInstaller.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace WinGetSourceCreator.Model +{ + public class SourceInstaller : Installer + { + public SourceInstaller(string workingDirectory, DynamicInstaller installer) + { + this.Initialize(installer); + this.InstallerFile = installer.Create(workingDirectory); + } + + public SourceInstaller(string workingDirectory, LocalInstaller installer) + { + this.Initialize(installer); + this.InstallerFile = Path.Combine(workingDirectory, this.Name); + var parent = Path.GetDirectoryName(this.InstallerFile); + if (!string.IsNullOrEmpty(parent)) + { + Directory.CreateDirectory(parent); + } + File.Copy(installer.Input, this.InstallerFile, true); + } + + public string InstallerFile { get; private set; } + + private void Initialize(Installer installer) + { + foreach (var installerProperty in installer.GetType().GetProperties()) + { + var toProperty = this.GetType().GetProperty(installerProperty.Name); + if (toProperty != null) + { + toProperty.SetValue(this, installerProperty.GetValue(installer)); + } + } + } + } +} diff --git a/src/WinGetSourceCreator/WinGetLocalSource.cs b/src/WinGetSourceCreator/WinGetLocalSource.cs @@ -0,0 +1,284 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.WinGetSourceCreator +{ + using global::WinGetSourceCreator.Model; + using System.Text.Json.Serialization; + using System.Text.Json; + using WinGetUtilInterop.Helpers; + + public class WinGetLocalSource + { + private readonly string workingDirectory; + private readonly ManifestTokens tokens; + private readonly Signature? signature; + + public static void CreateFromLocalSourceFile(string localSourceFile) + { + var content = File.ReadAllText(localSourceFile); + content = Environment.ExpandEnvironmentVariables(content); + + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + Converters = + { + new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) + } + }; + + content = content.Replace("\\", "/"); + + var localSource = JsonSerializer.Deserialize<LocalSource>(content, options); + if (localSource == null) + { + throw new Exception("Failed deserializing"); + } + + CreateLocalSource(localSource); + } + + public static void CreateLocalSource(LocalSource localSource) + { + localSource.Validate(); + + var wingetSource = new WinGetLocalSource(localSource.WorkingDirectory, localSource.Signature); + + if (localSource.LocalInstallers != null) + { + foreach (var installer in localSource.LocalInstallers) + { + wingetSource.PrepareLocalInstaller(installer); + } + } + + if (localSource.DynamicInstallers != null) + { + foreach (var installer in localSource.DynamicInstallers) + { + wingetSource.PrepareDynamicInstaller(installer); + } + } + + foreach (var localManifest in localSource.LocalManifests) + { + wingetSource.PrepareManifest(localManifest); + } + + var indexFile = wingetSource.CreateIndex(localSource.GetIndexName()); + + _ = wingetSource.CreatePackage(localSource.GetSourceName(), localSource.AppxManifest, indexFile, localSource.Signature); + } + + public WinGetLocalSource(string workingDirectory, Signature? signature) + { + this.workingDirectory = Path.GetFullPath(workingDirectory); + + if (Directory.Exists(workingDirectory)) + { + Directory.Delete(workingDirectory, true); + } + Directory.CreateDirectory(workingDirectory); + + this.tokens = new(); + this.signature = signature; + } + + public void PrepareDynamicInstaller(DynamicInstaller installer) + { + var sourceInstaller = new SourceInstaller(this.workingDirectory, installer); + PrepareInstaller(sourceInstaller); + } + + public void PrepareLocalInstaller(LocalInstaller installer) + { + var sourceInstaller = new SourceInstaller(this.workingDirectory, installer); + PrepareInstaller(sourceInstaller); + } + + public void PrepareManifest(string input) + { + if (File.Exists(input)) + { + + CopyManifestFile(input, Path.Combine(this.workingDirectory, Path.GetFileName(input))); + } + else + { + CopyManifestFiles(input, this.workingDirectory); + } + } + + public string CreateIndex(string indexName) + { + string fullPath = Path.Combine(this.workingDirectory, indexName); + using var indexHelper = WinGetUtilIndex.CreateLatestVersion(fullPath); + + Queue<string> filesQueue = new(Directory.EnumerateFiles(this.workingDirectory, "*.yaml", SearchOption.AllDirectories)); + while (filesQueue.Count > 0) + { + int currentCount = filesQueue.Count; + + for (int i = 0; i < currentCount; i++) + { + string file = filesQueue.Dequeue(); + try + { + var rel = Path.GetRelativePath(this.workingDirectory, file); + indexHelper.AddManifest(file, rel); + } + catch + { + // If adding manifest to index fails, add to queue and try again. + // This can occur if there is a package dependency that has not yet been added to the index. + filesQueue.Enqueue(file); + } + } + + if (filesQueue.Count == currentCount) + { + throw new InvalidOperationException("Failed to add all manifests in directory to index."); + } + } + + indexHelper.PrepareForPackaging(); + + return fullPath; + } + + public string CreatePackage(string packageName, string inputAppxManifestFile, string indexPath, Signature? signature) + { + if (!File.Exists(inputAppxManifestFile)) + { + throw new FileNotFoundException(inputAppxManifestFile); + } + + if (!File.Exists(indexPath)) + { + throw new FileNotFoundException(indexPath); + } + + string appxManifestFile = Path.Combine(this.workingDirectory, "AppxManifest.xml"); + File.Copy(inputAppxManifestFile, appxManifestFile); + + if (signature != null && signature.Publisher != null) + { + Helpers.ModifyAppxManifestIdentity(appxManifestFile, signature.Publisher); + } + + string mappingFile = Path.Combine(this.workingDirectory, "MappingFile.txt"); + + { + using StreamWriter outputFile = new(mappingFile); + outputFile.WriteLine("[Files]"); + outputFile.WriteLine($"\"{indexPath}\" \"Public\\{Path.GetFileName(indexPath)}\""); + outputFile.WriteLine($"\"{appxManifestFile}\" \"AppxManifest.xml\""); + } + + string outputPackage = Path.Combine(this.workingDirectory, packageName); + Helpers.PackWithMappingFile(outputPackage, mappingFile); + + if (signature != null) + { + Helpers.SignFile(outputPackage, signature); + } + + return outputPackage; + } + + // Copies all .yaml files + private void CopyManifestFiles(string sourceDir, string destDir) + { + DirectoryInfo dir = new DirectoryInfo(sourceDir); + DirectoryInfo[] dirs = dir.GetDirectories(); + + FileInfo[] files = dir.GetFiles(); + foreach (FileInfo file in files) + { + if (file.Extension == ".yaml") + { + CopyManifestFile(file.FullName, Path.Combine(destDir, file.Name)); + } + } + + foreach (DirectoryInfo subdir in dirs) + { + CopyManifestFiles(subdir.FullName, Path.Combine(destDir, subdir.Name)); + } + } + + // Copies a file and replaces any token found. + private void CopyManifestFile(string sourceFile, string destinationFile) + { + if (!File.Exists(sourceFile)) + { + throw new FileNotFoundException(sourceFile); + } + + var content = File.ReadAllText(sourceFile); + + foreach (var token in this.tokens.Tokens) + { + if (content.Contains(token.Key)) + { + content = content.Replace(token.Key, token.Value); + } + } + + File.WriteAllText(destinationFile, content); + } + + private void PrepareInstaller(SourceInstaller installer) + { + // Sign installer if needed. + if (!installer.SkipSignature) + { + var sig = this.GetSignature(installer); + if (sig != null) + { + Helpers.SignInstaller(installer, sig); + } + } + + // Process hash token if needed. + if (!string.IsNullOrEmpty(installer.HashToken)) + { + this.tokens.AddHashToken(installer.InstallerFile, installer.HashToken); + } + + // Extra steps. + // An msix can include the signature token. + if (installer.Type == InstallerType.Msix) + { + if (!string.IsNullOrEmpty(installer.SignatureToken)) + { + var signatureFilePath = Helpers.GetSignatureFileFromMsix(installer.InstallerFile); + this.tokens.AddHashToken(signatureFilePath, installer.SignatureToken); + + try + { + var dir = Path.GetDirectoryName(signatureFilePath); + if (!string.IsNullOrEmpty(dir)) + { + Directory.Delete(dir, true); + } + } + catch (Exception) + { + } + } + } + } + + private Signature? GetSignature(Installer installer) + { + if (installer.Type == InstallerType.Zip) + { + return null; + } + + return installer.Signature == null ? this.signature : installer.Signature; + } + } +} diff --git a/src/WinGetSourceCreator/WinGetSourceCreator.csproj b/src/WinGetSourceCreator/WinGetSourceCreator.csproj @@ -0,0 +1,18 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net6.0</TargetFramework> + <OutDir>$(SolutionDir)$(Platform)\$(Configuration)\WinGetSourceCreator\</OutDir> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.Msix.Utils" Version="2.1.1" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\WinGetUtilInterop\WinGetUtilInterop.csproj" /> + </ItemGroup> + +</Project> diff --git a/src/WinGetUtilInterop/Exceptions/WinGetUtilIndexException.cs b/src/WinGetUtilInterop/Exceptions/WinGetUtilIndexException.cs @@ -0,0 +1,48 @@ +// ----------------------------------------------------------------------- +// <copyright file="WinGetUtilSQLiteIndexException.cs" company="Microsoft Corporation"> +// Copyright (c) Microsoft Corporation. All rights reserved. +// </copyright> +// ----------------------------------------------------------------------- + +namespace WinGetUtilInterop.Exceptions +{ + using System; + + public class WinGetUtilIndexException : Exception + { + /// <summary> + /// Initializes a new instance of the <see cref="WinGetUtilIndexException"/> class. + /// </summary> + public WinGetUtilIndexException() + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="WinGetUtilIndexException"/> class. + /// </summary> + /// <param name="message">Message.</param> + public WinGetUtilIndexException(string message) + : base(message) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="WinGetUtilIndexException"/> class. + /// </summary> + /// <param name="inner">Inner exception.</param> + public WinGetUtilIndexException(Exception inner) + : base(string.Empty, inner) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="WinGetUtilIndexException"/> class. + /// </summary> + /// <param name="message">Message.</param> + /// <param name="inner">Inner exception.</param> + public WinGetUtilIndexException(string message, Exception inner) + : base(message, inner) + { + } + } +} diff --git a/src/WinGetUtilInterop/Helpers/WinGetUtilIndex.cs b/src/WinGetUtilInterop/Helpers/WinGetUtilIndex.cs @@ -0,0 +1,278 @@ +// ----------------------------------------------------------------------- +// <copyright file="WinGetUtilWrapperSQLiteIndex.cs" company="Microsoft Corporation"> +// Copyright (c) Microsoft Corporation. All rights reserved. +// </copyright> +// ----------------------------------------------------------------------- + +namespace WinGetUtilInterop.Helpers +{ + using System; + using System.Runtime.InteropServices; + using Microsoft.WinGetUtil.Interfaces; + using WinGetUtilInterop.Exceptions; + + public class WinGetUtilIndex : IWinGetUtilIndex + { + private const string WinGetUtilDll = "WinGetUtil.dll"; + private const uint IndexLatestVersion = unchecked((uint)-1); + + /// <summary> + /// WinGet Index latest version. + /// </summary> + public const uint WinGetIndexLatestVersion = unchecked((uint)-1); + + private readonly IntPtr indexHandle; + + /// <summary> + /// Initializes a new instance of the <see cref="WinGetUtilIndex"/> class. + /// </summary> + /// <param name="indexHandle">Handle of the index.</param> + /// <param name="loggingContext">Logging Context.</param> + private WinGetUtilIndex(IntPtr indexHandle) + { + this.indexHandle = indexHandle; + } + + /// <summary> + /// Creates a new index file in the specified path. + /// </summary> + /// <param name="indexFile">Index file to create.</param> + /// <param name="majorVersion">Major version.</param> + /// <param name="minorVersion">Minor version.</param> + /// <param name="loggingContext">Logging Context.</param> + /// <returns>Instance of IWinGetUtilSQLiteIndex.</returns> + public static IWinGetUtilIndex Create(string indexFile, uint majorVersion, uint minorVersion) + { + try + { + WinGetSQLiteIndexCreate( + indexFile, + majorVersion, + minorVersion, + out IntPtr index); + return new WinGetUtilIndex(index); + } + catch (Exception e) + { + throw new WinGetUtilIndexException(e); + } + } + + /// <summary> + /// Creates a new index file in the specified path. + /// </summary> + /// <param name="indexFile">Index file to create.</param> + /// <returns>Instance of IWinGetUtilSQLiteIndex.</returns> + public static IWinGetUtilIndex CreateLatestVersion(string indexFile) + { + return Create(indexFile, IndexLatestVersion, IndexLatestVersion); + } + + /// <summary> + /// Open the index file. + /// </summary> + /// <param name="indexFile">Index file to open.</param> + /// <returns>Instance of IWinGetUtilSQLiteIndex.</returns> + public static IWinGetUtilIndex Open(string indexFile) + { + try + { + WinGetSQLiteIndexOpen(indexFile, out IntPtr index); + return new WinGetUtilIndex(index); + } + catch (Exception e) + { + throw new WinGetUtilIndexException(e); + } + } + + /// <inheritdoc/> + public void AddManifest(string manifestPath, string relativePath) + { + try + { + WinGetSQLiteIndexAddManifest(this.indexHandle, manifestPath, relativePath); + return; + } + catch (Exception e) + { + throw new WinGetUtilIndexException(e); + } + } + + /// <inheritdoc/> + public bool UpdateManifest(string manifestPath, string relativePath) + { + try + { + // For now, modifying a manifest implies that the file didn't got moved in the repository. So only + // contents of the file are modified. However, in the future we might support moving which requires + // oldManifestPath, oldRelativePath, newManifestPath and oldManifestPath. + WinGetSQLiteIndexUpdateManifest( + this.indexHandle, + manifestPath, + relativePath, + out bool indexModified); + return indexModified; + } + catch (Exception e) + { + throw new WinGetUtilIndexException(e); + } + } + + /// <inheritdoc/> + public void RemoveManifest(string manifestPath, string relativePath) + { + try + { + WinGetSQLiteIndexRemoveManifest(this.indexHandle, manifestPath, relativePath); + return; + } + catch (Exception e) + { + throw new WinGetUtilIndexException(e); + } + } + + /// <inheritdoc/> + public void PrepareForPackaging() + { + try + { + WinGetSQLiteIndexPrepareForPackaging(this.indexHandle); + return; + } + catch (Exception e) + { + throw new WinGetUtilIndexException(e); + } + } + + /// <inheritdoc/> + public bool IsIndexConsistent() + { + try + { + WinGetSQLiteIndexCheckConsistency(this.indexHandle, out bool indexModified); + return indexModified; + } + catch (Exception e) + { + throw new WinGetUtilIndexException(e); + } + } + + /// <inheritdoc/> + public IntPtr GetIndexHandle() + { + return this.indexHandle; + } + + /// <summary> + /// Dispose method. + /// </summary> + public void Dispose() + { + this.Dispose(true); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Dispose method to free the sqlite index handle. + /// </summary> + /// <param name="disposing">Bool value indicating if Dispose is being run.</param> + public void Dispose(bool disposing) + { + if (disposing) + { + if (this.indexHandle != null) + { + WinGetSQLiteIndexClose(this.indexHandle); + } + } + } + + /// <summary> + /// Creates a new index file at filePath with the given version. + /// </summary> + /// <param name="filePath">File path to create index.</param> + /// <param name="majorVersion">Major version.</param> + /// <param name="minorVersion">Minor version.</param> + /// <param name="index">Out handle of the index.</param> + /// <returns>HRESULT.</returns> + [DllImport(WinGetUtilDll, CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode, PreserveSig = false)] + private static extern IntPtr WinGetSQLiteIndexCreate(string filePath, uint majorVersion, uint minorVersion, out IntPtr index); + + /// <summary> + /// Opens an existing index at filePath. + /// </summary> + /// <param name="filePath">File path of index.</param> + /// <param name="index">Out handle of the index.</param> + /// <returns>HRESULT.</returns> + [DllImport(WinGetUtilDll, CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode, PreserveSig = false)] + private static extern IntPtr WinGetSQLiteIndexOpen(string filePath, out IntPtr index); + + /// <summary> + /// Closes the index. + /// </summary> + /// <param name="index">Handle of the index.</param> + /// <returns>HRESULT.</returns> + [DllImport(WinGetUtilDll, CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode, PreserveSig = false)] + private static extern IntPtr WinGetSQLiteIndexClose(IntPtr index); + + /// <summary> + /// Adds the manifest at the repository relative path to the index. + /// If the function succeeds, the manifest has been added. + /// </summary> + /// <param name="index">Handle of the index.</param> + /// <param name="manifestPath">Manifest to add.</param> + /// <param name="relativePath">Path of the manifest in the container.</param> + /// <returns>HRESULT.</returns> + [DllImport(WinGetUtilDll, CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode, PreserveSig = false)] + private static extern IntPtr WinGetSQLiteIndexAddManifest(IntPtr index, string manifestPath, string relativePath); + + /// <summary> + /// Updates the manifest at the repository relative path in the index. + /// The out value indicates whether the index was modified by the function. + /// </summary> + /// <param name="index">Handle of the index.</param> + /// <param name="manifestPath">Old manifest path.</param> + /// <param name="relativePath">Old relative path in the container.</param> + /// <param name="indexModified">Out bool if the index is modified.</param> + /// <returns>HRESULT.</returns> + [DllImport(WinGetUtilDll, CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode, PreserveSig = false)] + private static extern IntPtr WinGetSQLiteIndexUpdateManifest( + IntPtr index, + string manifestPath, + string relativePath, + [MarshalAs(UnmanagedType.U1)] out bool indexModified); + + /// <summary> + /// Removes the manifest at the repository relative path from the index. + /// </summary> + /// <param name="index">Index handle.</param> + /// <param name="manifestPath">Manifest path to remove.</param> + /// <param name="relativePath">Relative path in the container.</param> + /// <returns>HRESULT.</returns> + [DllImport(WinGetUtilDll, CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode, PreserveSig = false)] + private static extern IntPtr WinGetSQLiteIndexRemoveManifest(IntPtr index, string manifestPath, string relativePath); + + /// <summary> + /// Removes data that is no longer needed for an index that is to be published. + /// </summary> + /// <param name="index">Index handle.</param> + /// <returns>HRESULT.</returns> + [DllImport(WinGetUtilDll, CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode, PreserveSig = false)] + private static extern IntPtr WinGetSQLiteIndexPrepareForPackaging(IntPtr index); + + /// <summary> + /// Checks the index for consistency, ensuring that at a minimum all referenced rows actually exist. + /// </summary> + /// <param name="index">Index handle.</param> + /// <param name="succeeded">Does the consistency check succeeded.</param> + /// <returns>HRESULT.</returns> + [DllImport(WinGetUtilDll, CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode, PreserveSig = false)] + private static extern IntPtr WinGetSQLiteIndexCheckConsistency(IntPtr index, [MarshalAs(UnmanagedType.U1)] out bool succeeded); + } +} diff --git a/src/WinGetUtilInterop/Interfaces/IWinGetUtilIndex.cs b/src/WinGetUtilInterop/Interfaces/IWinGetUtilIndex.cs @@ -0,0 +1,52 @@ +// ----------------------------------------------------------------------- +// <copyright file="WinGetUtilWrapperManifest.cs" company="Microsoft Corporation"> +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +// </copyright> +// ----------------------------------------------------------------------- + +namespace Microsoft.WinGetUtil.Interfaces +{ + using System; + + public interface IWinGetUtilIndex : IDisposable + { + /// <summary> + /// Adds manifest to index. + /// </summary> + /// <param name="manifestPath">Manifest to add.</param> + /// <param name="relativePath">Path of the manifest in the repository.</param> + void AddManifest(string manifestPath, string relativePath); + + /// <summary> + /// Updates manifest in the index. + /// </summary> + /// <param name="manifestPath">Path to manifest to modify.</param> + /// <param name="relativePath">Path of the manifest in the repository.</param> + /// <returns>True if index was modified.</returns> + bool UpdateManifest(string manifestPath, string relativePath); + + /// <summary> + /// Delete manifest from index. + /// </summary> + /// <param name="manifestPath">Path to manifest to modify.</param> + /// <param name="relativePath">Path of the manifest in the repository.</param> + void RemoveManifest(string manifestPath, string relativePath); + + /// <summary> + /// Wrapper for WinGetSQLiteIndexPrepareForPackaging. + /// </summary> + void PrepareForPackaging(); + + /// <summary> + /// Checks the index for consistency, ensuring that at a minimum all referenced rows actually exist. + /// </summary> + /// <returns>Is index consistent.</returns> + bool IsIndexConsistent(); + + /// <summary> + /// Gets the managed index handle. It is used in additional manifest validation that requires an index. + /// </summary> + /// <returns>The managed index handle.</returns> + IntPtr GetIndexHandle(); + } +} diff --git a/src/WinGetUtilInterop/WinGetUtilInterop.csproj b/src/WinGetUtilInterop/WinGetUtilInterop.csproj @@ -5,7 +5,6 @@ </PropertyGroup> <ItemGroup> - <Folder Include="Helpers\" /> <Folder Include="Manifest\" /> </ItemGroup> diff --git a/templates/e2e-setup.yml b/templates/e2e-setup.yml @@ -0,0 +1,38 @@ +# Configures local test source and local PowerShell repository. +parameters: +- name: source + type: string +- name: buildOutDir + type: string + +steps: + - task: DownloadSecureFile@1 + name: AppInstallerTest + displayName: 'Download Source Package Certificate' + inputs: + secureFile: 'AppInstallerTest.pfx' + + - task: DownloadSecureFile@1 + name: HTTPSDevCert + displayName: 'Download Kestrel Certificate' + inputs: + secureFile: 'HTTPSDevCertV2.pfx' + + - task: PowerShell@2 + displayName: Install Root Certificate + inputs: + filePath: '${{ parameters.source }}\src\LocalhostWebServer\InstallDevCert.ps1' + arguments: '-pfxpath $(HTTPSDevCert.secureFilePath) -password microsoft' + + - task: PowerShell@2 + displayName: Launch LocalhostWebServer + inputs: + filePath: '${{ parameters.source }}\src\LocalhostWebServer\Run-LocalhostWebServer.ps1' + arguments: '-BuildRoot ${{ parameters.buildOutDir }}\LocalhostWebServer -StaticFileRoot $(Agent.TempDirectory)\TestLocalIndex -CertPath $(HTTPSDevCert.secureFilePath) -CertPassword microsoft -OutCertFile $(Agent.TempDirectory)\servercert.cer -LocalSourceJson ${{ parameters.source }}\src\AppInstallerCLIE2ETests\TestData\localsource.json' + + - task: PowerShell@2 + displayName: Setup Local PS Repository + inputs: + filePath: '${{ parameters.source }}\src\AppInstallerCLIE2ETests\TestData\Configuration\Init-TestRepository.ps1' + arguments: '-Force' + pwsh: true diff --git a/templates/e2e-test.template.yml b/templates/e2e-test.template.yml @@ -5,14 +5,11 @@ parameters: type: boolean - name: filter type: string -- name: comTrace - type: boolean - default: false steps: - task: CmdLine@2 displayName: Start COM trace for ${{ parameters.title }} - condition: and(succeededOrFailed(), ${{ parameters.comTrace }}) + condition: and(succeededOrFailed(), eq(variables['System.debug'], true)) inputs: script: 'wpr -start $(Build.SourcesDirectory)\tools\COMTrace\ComTrace.wprp -filemode' @@ -23,7 +20,6 @@ steps: testSelector: 'testAssemblies' testAssemblyVer2: '$(buildOutDir)\AppInstallerCLIE2ETests\AppInstallerCLIE2ETests.dll' testFiltercriteria: ${{ parameters.filter }} - runSettingsFile: '$(buildOutDir)\AppInstallerCLIE2ETests\Test.runsettings' ${{ if eq(parameters.isPackaged, true) }}: overrideTestrunParameters: '-PackagedContext true -AICLIPackagePath $(packageLayoutDir) @@ -31,24 +27,20 @@ steps: -LooseFileRegistration true -InvokeCommandInDesktopPackage true -StaticFileRootPath $(Agent.TempDirectory)\TestLocalIndex - -MsiTestInstallerPath $(System.DefaultWorkingDirectory)\src\AppInstallerCLIE2ETests\TestData\AppInstallerTestMsiInstaller.msi - -MsixTestInstallerPath $(Build.ArtifactStagingDirectory)\AppInstallerTestMsixInstaller.msix - -ExeTestInstallerPath $(buildOutDir)\AppInstallerTestExeInstaller\AppInstallerTestExeInstaller.exe - -PackageCertificatePath $(AppInstallerTest.secureFilePath) - -PowerShellModulePath $(buildOutDir)\PowerShell\Microsoft.WinGet.Client\Microsoft.WinGet.Client.psd1' + -PowerShellModulePath $(buildOutDir)\PowerShell\Microsoft.WinGet.Client\Microsoft.WinGet.Client.psd1 + -LocalServerCertPath $(Agent.TempDirectory)\servercert.cer + -SkipTestSource true' ${{ else }}: overrideTestrunParameters: '-PackagedContext false -AICLIPath $(packageLayoutDir)\AppInstallerCLI\winget.exe -InvokeCommandInDesktopPackage false -StaticFileRootPath $(Agent.TempDirectory)\TestLocalIndex - -MsiTestInstallerPath $(System.DefaultWorkingDirectory)\src\AppInstallerCLIE2ETests\TestData\AppInstallerTestMsiInstaller.msi - -MsixTestInstallerPath $(Build.ArtifactStagingDirectory)\AppInstallerTestMsixInstaller.msix - -ExeTestInstallerPath $(buildOutDir)\AppInstallerTestExeInstaller\AppInstallerTestExeInstaller.exe - -PackageCertificatePath $(AppInstallerTest.secureFilePath) - -PowerShellModulePath $(buildOutDir)\PowerShell\Microsoft.WinGet.Client\Microsoft.WinGet.Client.psd1' + -PowerShellModulePath $(buildOutDir)\PowerShell\Microsoft.WinGet.Client\Microsoft.WinGet.Client.psd1 + -LocalServerCertPath $(Agent.TempDirectory)\servercert.cer + -SkipTestSource true' - task: CmdLine@2 displayName: Complete COM trace for ${{ parameters.title }} - condition: and(succeededOrFailed(), ${{ parameters.comTrace }}) + condition: and(succeededOrFailed(), eq(variables['System.debug'], true)) inputs: script: 'wpr -stop "$(artifactsDir)\ComTrace - ${{ parameters.title }}.etl"'