From 4e44a923c280f91e0d8c2baedb14277f71b63ba4 Mon Sep 17 00:00:00 2001 From: Union Palenshus Date: Mon, 7 Jun 2021 10:41:36 -0700 Subject: [PATCH] Modifying Update command to update ProductCode for MSIs (#67) --- doc/update.md | 8 +- src/WingetCreateCLI/Program.cs | 39 +- src/WingetCreateCore/Common/PackageParser.cs | 907 ++++++++++--------- 3 files changed, 498 insertions(+), 456 deletions(-) diff --git a/doc/update.md b/doc/update.md index 24abc420..217fc804 100644 --- a/doc/update.md +++ b/doc/update.md @@ -5,7 +5,7 @@ The **update** command of the [Winget-Create](../README.md) tool is designed to ## Usage -`WingetCreateCLI.exe update [] [\]` +`WingetCreateCLI.exe update [-u ] [-v ] [-s] [-t ] [-o ]` The **update** command can be called with the optional URL. If the URL is provided, **Winget-Create** will download the installer as it begins. If the URL is not included, the user will need to add it when prompted. @@ -15,9 +15,9 @@ The following arguments are available: | Argument | Description | |--------------|-------------| -| **-i, --id** | Required. Package identifier used to lookup the existing manifest on the Windows Package Manager repo. Id is case-sensitive. -| **-v, --version** | Version to be used when updating the package version field. +| **id** | Required. Package identifier used to lookup the existing manifest on the Windows Package Manager repo. | **-u, --url** | Installer Url used to extract relevant metadata for generating a manifest +| **-v, --version** | Version to be used when updating the package version field. | **-o, --out** | The output directory where the newly created manifests will be saved locally | **-s, --submit** | Boolean value for submitting to the Windows Package Manager repo. If true, updated manifest will be submitted directly using the provided GitHub Token | **-t, --token** | GitHub personal access token used for direct submission to the Windows Package Manager repo. If no token is provided, tool will prompt for GitHub login credentials. @@ -28,7 +28,7 @@ The update command allows you to quickly and easily update your manifest and sub 1) publish your installer to known URL 2) call the WingetCreateCLI.exe -`WingetCreateCLI.exe update --id --url --token --version ` +`WingetCreateCLI.exe update --url --token --version ` ### PackageIdentifier diff --git a/src/WingetCreateCLI/Program.cs b/src/WingetCreateCLI/Program.cs index 6dd5419a..81c25fd7 100644 --- a/src/WingetCreateCLI/Program.cs +++ b/src/WingetCreateCLI/Program.cs @@ -4,6 +4,7 @@ namespace Microsoft.WingetCreateCLI { using System; + using System.Collections.Generic; using System.Threading.Tasks; using CommandLine; using CommandLine.Text; @@ -37,7 +38,7 @@ private static async Task Main(string[] args) BaseCommand command = parserResult.MapResult(c => c as BaseCommand, err => null); if (command == null) { - DisplayHelp(parserResult); + DisplayHelp(parserResult as NotParsed); return 1; } @@ -60,7 +61,7 @@ private static async Task Main(string[] args) } } - private static int DisplayHelp(ParserResult result) + private static void DisplayHelp(NotParsed result) { var helpText = HelpText.AutoBuild( result, @@ -81,7 +82,39 @@ private static int DisplayHelp(ParserResult result) e => e, verbsIndex: true); Console.WriteLine(helpText); - return -1; + Console.WriteLine(); + + foreach (var error in result.Errors) + { + if (error is SetValueExceptionError sve) + { + Utils.WriteLineColored(ConsoleColor.Red, $"{sve.NameInfo.LongName}: {sve.Exception.Message}"); + if (sve.Exception.InnerException != null) + { + Utils.WriteLineColored(ConsoleColor.Red, $"{sve.Exception.InnerException.Message}"); + } + + if (sve.Value is IEnumerable list) + { + foreach (var val in list) + { + Utils.WriteLineColored(ConsoleColor.Red, $"\t{val}"); + } + } + else + { + Utils.WriteLineColored(ConsoleColor.Red, $"\t{sve.Value}"); + } + } + else if (error is UnknownOptionError uoe) + { + Utils.WriteLineColored(ConsoleColor.Red, $"Unknown option: {uoe.Token}"); + } + else + { + Utils.WriteLineColored(ConsoleColor.Red, $"Command line parsing error: {error.Tag}"); + } + } } } } diff --git a/src/WingetCreateCore/Common/PackageParser.cs b/src/WingetCreateCore/Common/PackageParser.cs index a412bf22..40e95d5d 100644 --- a/src/WingetCreateCore/Common/PackageParser.cs +++ b/src/WingetCreateCore/Common/PackageParser.cs @@ -1,453 +1,462 @@ -// Copyright (c) Microsoft. All rights reserved. -// Licensed under the MIT license. - -namespace Microsoft.WingetCreateCore -{ - using System; - using System.Collections.Generic; - using System.ComponentModel; - using System.Diagnostics; - using System.Globalization; - using System.IO; - using System.Linq; - using System.Net.Http; - using System.Runtime.InteropServices; - using System.Security.Cryptography; - using System.Text.RegularExpressions; - using System.Threading.Tasks; - using System.Xml; - using Microsoft.Deployment.WindowsInstaller.Linq; - using Microsoft.Msix.Utils; - using Microsoft.Msix.Utils.AppxPackaging; - using Microsoft.Msix.Utils.AppxPackagingInterop; - using Microsoft.WingetCreateCore.Common; - using Microsoft.WingetCreateCore.Models; - using Microsoft.WingetCreateCore.Models.DefaultLocale; - using Microsoft.WingetCreateCore.Models.Installer; - using Microsoft.WingetCreateCore.Models.Version; - using Newtonsoft.Json; - using Vestris.ResourceLib; - - /// - /// Provides functionality for a parsing and extracting relevant metadata from a given package. - /// - public static class PackageParser - { - private const string InvalidCharacters = "©|®"; - - private static readonly string[] KnownInstallerResourceNames = new[] - { - "inno", - "wix", - "nullsoft", - }; - - private static HttpClient httpClient = new HttpClient(); - - private enum MachineType - { - X86 = 0x014c, - X64 = 0x8664, - } - - /// - /// Sets the HttpMessageHandler used for the static HttpClient. - /// - /// Optional HttpMessageHandler to override default HttpClient behavior. - public static void SetHttpMessageHandler(HttpMessageHandler httpMessageHandler) - { - httpClient.Dispose(); - httpClient = httpMessageHandler != null ? new HttpClient(httpMessageHandler) : new HttpClient(); - } - - /// - /// Parses a package for available metadata including Version, Publisher, Name, Descripion, License, etc. - /// - /// Path to package file. - /// Installer url. - /// Wrapper object for manifest object models. - /// True if package was successfully parsed and metadata extracted, false otherwise. - public static bool ParsePackage( - string path, - string url, - Manifests manifests) - { - VersionManifest versionManifest = manifests.VersionManifest = new VersionManifest(); - - // TODO: Remove once default is set in schema - versionManifest.DefaultLocale = "en-US"; - - InstallerManifest installerManifest = manifests.InstallerManifest = new InstallerManifest(); - DefaultLocaleManifest defaultLocaleManifest = manifests.DefaultLocaleManifest = new DefaultLocaleManifest(); - - var versionInfo = FileVersionInfo.GetVersionInfo(path); - - var installer = new Installer(); - installer.InstallerUrl = url; - installer.InstallerSha256 = GetFileHash(path); - installer.Architecture = GetMachineType(path)?.ToString().ToEnumOrDefault() ?? InstallerArchitecture.Neutral; - installerManifest.Installers.Add(installer); - - defaultLocaleManifest.PackageVersion ??= versionInfo.FileVersion?.Trim() ?? versionInfo.ProductVersion?.Trim(); - defaultLocaleManifest.Publisher ??= versionInfo.CompanyName?.Trim(); - defaultLocaleManifest.PackageName ??= versionInfo.ProductName?.Trim(); - defaultLocaleManifest.ShortDescription ??= versionInfo.FileDescription?.Trim(); - defaultLocaleManifest.License ??= versionInfo.LegalCopyright?.Trim(); - - if (ParseExeInstallerType(path, installer) || - ParseMsix(path, manifests) || - ParseMsi(path, installer, manifests)) - { - if (!string.IsNullOrEmpty(defaultLocaleManifest.PackageVersion)) - { - versionManifest.PackageVersion = installerManifest.PackageVersion = RemoveInvalidCharsFromString(defaultLocaleManifest.PackageVersion); - } - - string packageIdPublisher = defaultLocaleManifest.Publisher?.Remove(" ").Trim('.') ?? $"<{nameof(defaultLocaleManifest.Publisher)}>"; - string packageIdName = defaultLocaleManifest.PackageName?.Remove(" ").Trim('.') ?? $"<{nameof(defaultLocaleManifest.PackageName)}>"; - versionManifest.PackageIdentifier = $"{RemoveInvalidCharsFromString(packageIdPublisher)}.{RemoveInvalidCharsFromString(packageIdName)}"; - installerManifest.PackageIdentifier = defaultLocaleManifest.PackageIdentifier = versionManifest.PackageIdentifier; - return true; - } - else - { - return false; - } - } - - /// - /// Download file at specified URL to temp directory, unless it's already present. - /// - /// The URL of the file to be downloaded. - /// The maximum file size in bytes to download. - /// Path of downloaded, or previously downloaded, file. - public static async Task DownloadFileAsync(string url, long? maxDownloadSize = null) +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. + +namespace Microsoft.WingetCreateCore +{ + using System; + using System.Collections.Generic; + using System.ComponentModel; + using System.Diagnostics; + using System.Globalization; + using System.IO; + using System.Linq; + using System.Net.Http; + using System.Runtime.InteropServices; + using System.Security.Cryptography; + using System.Text.RegularExpressions; + using System.Threading.Tasks; + using System.Xml; + using Microsoft.Deployment.WindowsInstaller.Linq; + using Microsoft.Msix.Utils; + using Microsoft.Msix.Utils.AppxPackaging; + using Microsoft.Msix.Utils.AppxPackagingInterop; + using Microsoft.WingetCreateCore.Common; + using Microsoft.WingetCreateCore.Models; + using Microsoft.WingetCreateCore.Models.DefaultLocale; + using Microsoft.WingetCreateCore.Models.Installer; + using Microsoft.WingetCreateCore.Models.Version; + using Newtonsoft.Json; + using Vestris.ResourceLib; + + /// + /// Provides functionality for a parsing and extracting relevant metadata from a given package. + /// + public static class PackageParser + { + private const string InvalidCharacters = "©|®"; + + private static readonly string[] KnownInstallerResourceNames = new[] + { + "inno", + "wix", + "nullsoft", + }; + + private static HttpClient httpClient = new HttpClient(); + + private enum MachineType + { + X86 = 0x014c, + X64 = 0x8664, + } + + /// + /// Sets the HttpMessageHandler used for the static HttpClient. + /// + /// Optional HttpMessageHandler to override default HttpClient behavior. + public static void SetHttpMessageHandler(HttpMessageHandler httpMessageHandler) + { + httpClient.Dispose(); + httpClient = httpMessageHandler != null ? new HttpClient(httpMessageHandler) : new HttpClient(); + } + + /// + /// Parses a package for available metadata including Version, Publisher, Name, Descripion, License, etc. + /// + /// Path to package file. + /// Installer url. + /// Wrapper object for manifest object models. + /// True if package was successfully parsed and metadata extracted, false otherwise. + public static bool ParsePackage( + string path, + string url, + Manifests manifests) + { + VersionManifest versionManifest = manifests.VersionManifest = new VersionManifest(); + + // TODO: Remove once default is set in schema + versionManifest.DefaultLocale = "en-US"; + + InstallerManifest installerManifest = manifests.InstallerManifest = new InstallerManifest(); + DefaultLocaleManifest defaultLocaleManifest = manifests.DefaultLocaleManifest = new DefaultLocaleManifest(); + + var versionInfo = FileVersionInfo.GetVersionInfo(path); + + var installer = new Installer(); + installer.InstallerUrl = url; + installer.InstallerSha256 = GetFileHash(path); + installer.Architecture = GetMachineType(path)?.ToString().ToEnumOrDefault() ?? InstallerArchitecture.Neutral; + installerManifest.Installers.Add(installer); + + defaultLocaleManifest.PackageVersion ??= versionInfo.FileVersion?.Trim() ?? versionInfo.ProductVersion?.Trim(); + defaultLocaleManifest.Publisher ??= versionInfo.CompanyName?.Trim(); + defaultLocaleManifest.PackageName ??= versionInfo.ProductName?.Trim(); + defaultLocaleManifest.ShortDescription ??= versionInfo.FileDescription?.Trim(); + defaultLocaleManifest.License ??= versionInfo.LegalCopyright?.Trim(); + + if (ParseExeInstallerType(path, installer) || + ParseMsix(path, manifests) || + ParseMsi(path, installer, manifests)) + { + if (!string.IsNullOrEmpty(defaultLocaleManifest.PackageVersion)) + { + versionManifest.PackageVersion = installerManifest.PackageVersion = RemoveInvalidCharsFromString(defaultLocaleManifest.PackageVersion); + } + + string packageIdPublisher = defaultLocaleManifest.Publisher?.Remove(" ").Trim('.') ?? $"<{nameof(defaultLocaleManifest.Publisher)}>"; + string packageIdName = defaultLocaleManifest.PackageName?.Remove(" ").Trim('.') ?? $"<{nameof(defaultLocaleManifest.PackageName)}>"; + versionManifest.PackageIdentifier = $"{RemoveInvalidCharsFromString(packageIdPublisher)}.{RemoveInvalidCharsFromString(packageIdName)}"; + installerManifest.PackageIdentifier = defaultLocaleManifest.PackageIdentifier = versionManifest.PackageIdentifier; + return true; + } + else + { + return false; + } + } + + /// + /// Download file at specified URL to temp directory, unless it's already present. + /// + /// The URL of the file to be downloaded. + /// The maximum file size in bytes to download. + /// Path of downloaded, or previously downloaded, file. + public static async Task DownloadFileAsync(string url, long? maxDownloadSize = null) { var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); - if (!response.IsSuccessStatusCode) - { - string message = await response.Content.ReadAsStringAsync(); - throw new HttpRequestException(message, null, response.StatusCode); - } - - string urlFile = Path.GetFileName(url.Split('?').Last()); - string contentDispositionFile = response.Content.Headers.ContentDisposition?.FileName?.Trim('"'); - string targetFile = Path.Combine(Path.GetTempPath(), contentDispositionFile ?? urlFile); - long? downloadSize = response.Content.Headers.ContentLength; - - if (downloadSize > maxDownloadSize) - { - string invalidDataExceptionMessage = $"URL points to file larger than the maximum size of {maxDownloadSize / 1024 / 1024}MB"; - throw new InvalidDataException(invalidDataExceptionMessage); - } - - if (!File.Exists(targetFile) || new FileInfo(targetFile).Length != downloadSize) + if (!response.IsSuccessStatusCode) + { + string message = await response.Content.ReadAsStringAsync(); + throw new HttpRequestException(message, null, response.StatusCode); + } + + string urlFile = Path.GetFileName(url.Split('?').Last()); + string contentDispositionFile = response.Content.Headers.ContentDisposition?.FileName?.Trim('"'); + string targetFile = Path.Combine(Path.GetTempPath(), contentDispositionFile ?? urlFile); + long? downloadSize = response.Content.Headers.ContentLength; + + if (downloadSize > maxDownloadSize) + { + string invalidDataExceptionMessage = $"URL points to file larger than the maximum size of {maxDownloadSize / 1024 / 1024}MB"; + throw new InvalidDataException(invalidDataExceptionMessage); + } + + if (!File.Exists(targetFile) || new FileInfo(targetFile).Length != downloadSize) { - File.Delete(targetFile); - using var targetFileStream = File.OpenWrite(targetFile); - var contentStream = await response.Content.ReadAsStreamAsync(); - await contentStream.CopyToAsync(targetFileStream); - } - - return targetFile; - } - - /// - /// Computes the SHA256 hash of a file given its file path. - /// - /// Path to file to be hashed. - /// The computed SHA256 hash string. - public static string GetFileHash(string path) - { - using Stream stream = File.OpenRead(path); - using var hasher = SHA256.Create(); - return BitConverter.ToString(hasher.ComputeHash(stream)).Remove("-"); - } - - /// - /// Update InstallerManifest's Installer nodes based on specified package file path. - /// - /// to update. - /// InstallerUrl where installer can be downloaded. - /// Path to package to extract metadata from. - public static void UpdateInstallerNodes(InstallerManifest installerManifest, string installerUrl, string packageFile) - { - string installerSha256 = GetFileHash(packageFile); - foreach (var installer in installerManifest.Installers) - { - installer.InstallerSha256 = installerSha256; - installer.InstallerUrl = installerUrl; - } - - GetAppxMetadataAndSetInstallerProperties(packageFile, installerManifest); - } - - /// - /// Computes the SHA256 hash value for the specified byte array. - /// - /// The input to compute the hash code for. - /// The computed SHA256 hash string. - private static string HashBytes(byte[] buffer) - { - using var hasher = SHA256.Create(); - return BitConverter.ToString(hasher.ComputeHash(buffer)).Remove("-"); - } - - private static string HashAppxFile(IAppxFile signatureFile) - { - var signatureBytes = StreamUtils.ReadStreamToByteArray(signatureFile.GetStream()); - return HashBytes(signatureBytes); - } - - private static MachineType? GetMachineType(string binary) - { - using (FileStream stream = File.OpenRead(binary)) - using (BinaryReader bw = new BinaryReader(stream)) - { - const ushort executableMagicNumber = 0x5a4d; - const int peMagicNumber = 0x00004550; // "PE\0\0" - - stream.Seek(0, SeekOrigin.Begin); - int magicNumber = bw.ReadUInt16(); - bool isExecutable = magicNumber == executableMagicNumber; - - if (isExecutable) - { - stream.Seek(60, SeekOrigin.Begin); - int headerOffset = bw.ReadInt32(); - - stream.Seek(headerOffset, SeekOrigin.Begin); - int signature = bw.ReadInt32(); - bool isPortableExecutable = signature == peMagicNumber; - - if (isPortableExecutable) - { - MachineType machineType = (MachineType)bw.ReadUInt16(); - - return machineType; - } - } - } - - return null; - } - - private static bool ParseExeInstallerType(string path, Installer installer) - { - try - { - ManifestResource rc = new ManifestResource(); - rc.LoadFrom(path); - string installerType = rc.Manifest.DocumentElement - .GetElementsByTagName("description") - .Cast() - .FirstOrDefault()? - .InnerText? - .Split(' ').First() - .ToLowerInvariant(); - - installer.InstallerType = KnownInstallerResourceNames.Contains(installerType) ? installerType.ToEnumOrDefault() : InstallerType.Exe; - - return true; - } - catch (Win32Exception) - { - // Installer doesn't have a resource header - return false; - } - } - - private static bool ParseMsi(string path, Installer installer, Manifests manifests) - { - VersionManifest versionManifest = manifests.VersionManifest; - InstallerManifest installerManifest = manifests.InstallerManifest; - DefaultLocaleManifest defaultLocaleManifest = manifests.DefaultLocaleManifest; - - try - { - using (var database = new QDatabase(path, Deployment.WindowsInstaller.DatabaseOpenMode.ReadOnly)) - { - installer.InstallerType = InstallerType.Msi; - - var properties = database.Properties.ToList(); - defaultLocaleManifest.PackageVersion ??= properties.FirstOrDefault(p => p.Property == "ProductVersion")?.Value; - defaultLocaleManifest.PackageName ??= properties.FirstOrDefault(p => p.Property == "ProductName")?.Value; - defaultLocaleManifest.Publisher ??= properties.FirstOrDefault(p => p.Property == "Manufacturer")?.Value; - installer.ProductCode = properties.FirstOrDefault(p => p.Property == "ProductCode")?.Value; - - string archString = database.SummaryInfo.Template.Split(';').First(); - installer.Architecture = archString.ToEnumOrDefault() ?? InstallerArchitecture.Neutral; - - if (installer.InstallerLocale == null) - { - string languageString = properties.FirstOrDefault(p => p.Property == "ProductLanguage")?.Value; - - if (int.TryParse(languageString, out int lcid)) - { - try - { - installer.InstallerLocale = new CultureInfo(lcid).Name; - } - catch (Exception ex) when (ex is ArgumentOutOfRangeException || ex is CultureNotFoundException) - { - // If the lcid value is invalid, do nothing. - } - } - } - } - - return true; - } - catch (Deployment.WindowsInstaller.InstallerException) - { - // Binary wasn't an MSI, skip - return false; - } - } - - private static bool ParseMsix(string path, Manifests manifests) - { - VersionManifest versionManifest = manifests.VersionManifest; - InstallerManifest installerManifest = manifests.InstallerManifest; - DefaultLocaleManifest defaultLocaleManifest = manifests.DefaultLocaleManifest; - - AppxMetadata metadata = GetAppxMetadataAndSetInstallerProperties(path, installerManifest); - if (metadata == null) - { - // Binary wasn't an MSIX, skip - return false; - } - - installerManifest.Installers.ForEach(i => i.InstallerType = InstallerType.Msix); - defaultLocaleManifest.PackageVersion = metadata.Version?.ToString(); - defaultLocaleManifest.PackageName ??= metadata.DisplayName; - defaultLocaleManifest.Publisher ??= metadata.PublisherDisplayName; - defaultLocaleManifest.ShortDescription ??= GetApplicationProperty(metadata, "Description"); - - return true; - } - - private static string GetApplicationProperty(AppxMetadata appxMetadata, string propertyName) - { - IAppxManifestApplicationsEnumerator enumerator = appxMetadata.AppxReader.GetManifest().GetApplications(); - - while (enumerator.GetHasCurrent()) - { - IAppxManifestApplication application = enumerator.GetCurrent(); - - try - { - application.GetStringValue(propertyName, out string value); - return value; - } - catch (ArgumentException) - { - // Property not found on this node, continue - } - - enumerator.MoveNext(); - } - - return null; - } - - private static Installer CloneInstaller(Installer installer) - { - string json = JsonConvert.SerializeObject(installer); - return JsonConvert.DeserializeObject(json); - } - - private static void SetInstallerPropertiesFromAppxMetadata(AppxMetadata appxMetadata, Installer installer, InstallerManifest installerManifest) - { - installer.Architecture = appxMetadata.Architecture.ToEnumOrDefault() ?? InstallerArchitecture.Neutral; - - installer.MinimumOSVersion = SetInstallerStringPropertyIfNeeded(installerManifest.MinimumOSVersion, appxMetadata.MinOSVersion?.ToString()); - installer.PackageFamilyName = SetInstallerStringPropertyIfNeeded(installerManifest.PackageFamilyName, appxMetadata.PackageFamilyName); - - // We have to fixup the Platform string first, and then remove anything that fails to parse. - var platformValues = appxMetadata.TargetDeviceFamiliesMinVersions.Keys - .Select(k => k.Replace('.', '_').ToEnumOrDefault()) - .Where(p => p != null) - .Select(p => p.Value) - .ToList(); - installer.Platform = SetInstallerListPropertyIfNeeded(installerManifest.Platform, platformValues); - } - - private static string SetInstallerStringPropertyIfNeeded(string rootProperty, string valueToSet) - { - return valueToSet == rootProperty ? null : valueToSet; - } - - private static List SetInstallerListPropertyIfNeeded(List rootProperty, List valueToSet) - { - return rootProperty != null && new HashSet(rootProperty).SetEquals(valueToSet) ? null : valueToSet; - } - - private static AppxMetadata GetAppxMetadataAndSetInstallerProperties(string path, InstallerManifest installerManifest) - { - try - { - var installers = installerManifest.Installers; - var appxMetadatas = new List(); - string signatureSha256; - - try - { - // Check if package is an MsixBundle - var bundle = new AppxBundleMetadata(path); - - IAppxFile signatureFile = bundle.AppxBundleReader.GetFootprintFile(APPX_BUNDLE_FOOTPRINT_FILE_TYPE.APPX_BUNDLE_FOOTPRINT_FILE_TYPE_SIGNATURE); - signatureSha256 = HashAppxFile(signatureFile); - - // Only create installer nodes for non-resource packages - foreach (var childPackage in bundle.ChildAppxPackages.Where(p => p.PackageType == PackageType.Application)) - { - var appxFile = bundle.AppxBundleReader.GetPayloadPackage(childPackage.RelativeFilePath); - appxMetadatas.Add(new AppxMetadata(appxFile.GetStream())); - } - } - catch (COMException) - { - // Check if package is an Msix - var appxMetadata = new AppxMetadata(path); - appxMetadatas.Add(appxMetadata); - IAppxFile signatureFile = appxMetadata.AppxReader.GetFootprintFile(APPX_FOOTPRINT_FILE_TYPE.APPX_FOOTPRINT_FILE_TYPE_SIGNATURE); - signatureSha256 = HashAppxFile(signatureFile); - } - - var firstInstaller = installers.First(); - - // Remove installer nodes which have no matching architecture in msix/bundle - installers.RemoveAll(i => !appxMetadatas.Any(m => m.Architecture.EqualsIC(i.Architecture.ToString()))); - - foreach (var appxMetadata in appxMetadatas) - { - InstallerArchitecture appxArchitecture = appxMetadata.Architecture.ToEnumOrDefault() ?? InstallerArchitecture.Neutral; - var matchingInstaller = installers.SingleOrDefault(i => i.Architecture == appxArchitecture); - if (matchingInstaller == null) - { - matchingInstaller = CloneInstaller(firstInstaller); - installers.Add(matchingInstaller); - } - - SetInstallerPropertiesFromAppxMetadata(appxMetadata, matchingInstaller, installerManifest); - } - - installers.ForEach(i => i.SignatureSha256 = signatureSha256); - - return appxMetadatas.First(); - } - catch (COMException) - { - // Binary wasn't an MSIX - return null; - } - } - - private static string RemoveInvalidCharsFromString(string value) - { - return Regex.Replace(value, InvalidCharacters, string.Empty); - } - } -} + File.Delete(targetFile); + using var targetFileStream = File.OpenWrite(targetFile); + var contentStream = await response.Content.ReadAsStreamAsync(); + await contentStream.CopyToAsync(targetFileStream); + } + + return targetFile; + } + + /// + /// Computes the SHA256 hash of a file given its file path. + /// + /// Path to file to be hashed. + /// The computed SHA256 hash string. + public static string GetFileHash(string path) + { + using Stream stream = File.OpenRead(path); + using var hasher = SHA256.Create(); + return BitConverter.ToString(hasher.ComputeHash(stream)).Remove("-"); + } + + /// + /// Update InstallerManifest's Installer nodes based on specified package file path. + /// + /// to update. + /// InstallerUrl where installer can be downloaded. + /// Path to package to extract metadata from. + public static void UpdateInstallerNodes(InstallerManifest installerManifest, string installerUrl, string packageFile) + { + string installerSha256 = GetFileHash(packageFile); + foreach (var installer in installerManifest.Installers) + { + installer.InstallerSha256 = installerSha256; + installer.InstallerUrl = installerUrl; + + // If installer is an MSI, update its ProductCode + var updatedInstaller = new Installer(); + if (ParseMsi(packageFile, updatedInstaller, null)) + { + installer.ProductCode = updatedInstaller.ProductCode; + } + } + + GetAppxMetadataAndSetInstallerProperties(packageFile, installerManifest); + } + + /// + /// Computes the SHA256 hash value for the specified byte array. + /// + /// The input to compute the hash code for. + /// The computed SHA256 hash string. + private static string HashBytes(byte[] buffer) + { + using var hasher = SHA256.Create(); + return BitConverter.ToString(hasher.ComputeHash(buffer)).Remove("-"); + } + + private static string HashAppxFile(IAppxFile signatureFile) + { + var signatureBytes = StreamUtils.ReadStreamToByteArray(signatureFile.GetStream()); + return HashBytes(signatureBytes); + } + + private static MachineType? GetMachineType(string binary) + { + using (FileStream stream = File.OpenRead(binary)) + using (BinaryReader bw = new BinaryReader(stream)) + { + const ushort executableMagicNumber = 0x5a4d; + const int peMagicNumber = 0x00004550; // "PE\0\0" + + stream.Seek(0, SeekOrigin.Begin); + int magicNumber = bw.ReadUInt16(); + bool isExecutable = magicNumber == executableMagicNumber; + + if (isExecutable) + { + stream.Seek(60, SeekOrigin.Begin); + int headerOffset = bw.ReadInt32(); + + stream.Seek(headerOffset, SeekOrigin.Begin); + int signature = bw.ReadInt32(); + bool isPortableExecutable = signature == peMagicNumber; + + if (isPortableExecutable) + { + MachineType machineType = (MachineType)bw.ReadUInt16(); + + return machineType; + } + } + } + + return null; + } + + private static bool ParseExeInstallerType(string path, Installer installer) + { + try + { + ManifestResource rc = new ManifestResource(); + rc.LoadFrom(path); + string installerType = rc.Manifest.DocumentElement + .GetElementsByTagName("description") + .Cast() + .FirstOrDefault()? + .InnerText? + .Split(' ').First() + .ToLowerInvariant(); + + installer.InstallerType = KnownInstallerResourceNames.Contains(installerType) ? installerType.ToEnumOrDefault() : InstallerType.Exe; + + return true; + } + catch (Win32Exception) + { + // Installer doesn't have a resource header + return false; + } + } + + private static bool ParseMsi(string path, Installer installer, Manifests manifests) + { + DefaultLocaleManifest defaultLocaleManifest = manifests?.DefaultLocaleManifest; + + try + { + using (var database = new QDatabase(path, Deployment.WindowsInstaller.DatabaseOpenMode.ReadOnly)) + { + installer.InstallerType = InstallerType.Msi; + + var properties = database.Properties.ToList(); + + if (defaultLocaleManifest != null) + { + defaultLocaleManifest.PackageVersion ??= properties.FirstOrDefault(p => p.Property == "ProductVersion")?.Value; + defaultLocaleManifest.PackageName ??= properties.FirstOrDefault(p => p.Property == "ProductName")?.Value; + defaultLocaleManifest.Publisher ??= properties.FirstOrDefault(p => p.Property == "Manufacturer")?.Value; + } + + installer.ProductCode = properties.FirstOrDefault(p => p.Property == "ProductCode")?.Value; + + string archString = database.SummaryInfo.Template.Split(';').First(); + installer.Architecture = archString.ToEnumOrDefault() ?? InstallerArchitecture.Neutral; + + if (installer.InstallerLocale == null) + { + string languageString = properties.FirstOrDefault(p => p.Property == "ProductLanguage")?.Value; + + if (int.TryParse(languageString, out int lcid)) + { + try + { + installer.InstallerLocale = new CultureInfo(lcid).Name; + } + catch (Exception ex) when (ex is ArgumentOutOfRangeException || ex is CultureNotFoundException) + { + // If the lcid value is invalid, do nothing. + } + } + } + } + + return true; + } + catch (Deployment.WindowsInstaller.InstallerException) + { + // Binary wasn't an MSI, skip + return false; + } + } + + private static bool ParseMsix(string path, Manifests manifests) + { + InstallerManifest installerManifest = manifests.InstallerManifest; + DefaultLocaleManifest defaultLocaleManifest = manifests.DefaultLocaleManifest; + + AppxMetadata metadata = GetAppxMetadataAndSetInstallerProperties(path, installerManifest); + if (metadata == null) + { + // Binary wasn't an MSIX, skip + return false; + } + + installerManifest.Installers.ForEach(i => i.InstallerType = InstallerType.Msix); + defaultLocaleManifest.PackageVersion = metadata.Version?.ToString(); + defaultLocaleManifest.PackageName ??= metadata.DisplayName; + defaultLocaleManifest.Publisher ??= metadata.PublisherDisplayName; + defaultLocaleManifest.ShortDescription ??= GetApplicationProperty(metadata, "Description"); + + return true; + } + + private static string GetApplicationProperty(AppxMetadata appxMetadata, string propertyName) + { + IAppxManifestApplicationsEnumerator enumerator = appxMetadata.AppxReader.GetManifest().GetApplications(); + + while (enumerator.GetHasCurrent()) + { + IAppxManifestApplication application = enumerator.GetCurrent(); + + try + { + application.GetStringValue(propertyName, out string value); + return value; + } + catch (ArgumentException) + { + // Property not found on this node, continue + } + + enumerator.MoveNext(); + } + + return null; + } + + private static Installer CloneInstaller(Installer installer) + { + string json = JsonConvert.SerializeObject(installer); + return JsonConvert.DeserializeObject(json); + } + + private static void SetInstallerPropertiesFromAppxMetadata(AppxMetadata appxMetadata, Installer installer, InstallerManifest installerManifest) + { + installer.Architecture = appxMetadata.Architecture.ToEnumOrDefault() ?? InstallerArchitecture.Neutral; + + installer.MinimumOSVersion = SetInstallerStringPropertyIfNeeded(installerManifest.MinimumOSVersion, appxMetadata.MinOSVersion?.ToString()); + installer.PackageFamilyName = SetInstallerStringPropertyIfNeeded(installerManifest.PackageFamilyName, appxMetadata.PackageFamilyName); + + // We have to fixup the Platform string first, and then remove anything that fails to parse. + var platformValues = appxMetadata.TargetDeviceFamiliesMinVersions.Keys + .Select(k => k.Replace('.', '_').ToEnumOrDefault()) + .Where(p => p != null) + .Select(p => p.Value) + .ToList(); + installer.Platform = SetInstallerListPropertyIfNeeded(installerManifest.Platform, platformValues); + } + + private static string SetInstallerStringPropertyIfNeeded(string rootProperty, string valueToSet) + { + return valueToSet == rootProperty ? null : valueToSet; + } + + private static List SetInstallerListPropertyIfNeeded(List rootProperty, List valueToSet) + { + return rootProperty != null && new HashSet(rootProperty).SetEquals(valueToSet) ? null : valueToSet; + } + + private static AppxMetadata GetAppxMetadataAndSetInstallerProperties(string path, InstallerManifest installerManifest) + { + try + { + var installers = installerManifest.Installers; + var appxMetadatas = new List(); + string signatureSha256; + + try + { + // Check if package is an MsixBundle + var bundle = new AppxBundleMetadata(path); + + IAppxFile signatureFile = bundle.AppxBundleReader.GetFootprintFile(APPX_BUNDLE_FOOTPRINT_FILE_TYPE.APPX_BUNDLE_FOOTPRINT_FILE_TYPE_SIGNATURE); + signatureSha256 = HashAppxFile(signatureFile); + + // Only create installer nodes for non-resource packages + foreach (var childPackage in bundle.ChildAppxPackages.Where(p => p.PackageType == PackageType.Application)) + { + var appxFile = bundle.AppxBundleReader.GetPayloadPackage(childPackage.RelativeFilePath); + appxMetadatas.Add(new AppxMetadata(appxFile.GetStream())); + } + } + catch (COMException) + { + // Check if package is an Msix + var appxMetadata = new AppxMetadata(path); + appxMetadatas.Add(appxMetadata); + IAppxFile signatureFile = appxMetadata.AppxReader.GetFootprintFile(APPX_FOOTPRINT_FILE_TYPE.APPX_FOOTPRINT_FILE_TYPE_SIGNATURE); + signatureSha256 = HashAppxFile(signatureFile); + } + + var firstInstaller = installers.First(); + + // Remove installer nodes which have no matching architecture in msix/bundle + installers.RemoveAll(i => !appxMetadatas.Any(m => m.Architecture.EqualsIC(i.Architecture.ToString()))); + + foreach (var appxMetadata in appxMetadatas) + { + InstallerArchitecture appxArchitecture = appxMetadata.Architecture.ToEnumOrDefault() ?? InstallerArchitecture.Neutral; + var matchingInstaller = installers.SingleOrDefault(i => i.Architecture == appxArchitecture); + if (matchingInstaller == null) + { + matchingInstaller = CloneInstaller(firstInstaller); + installers.Add(matchingInstaller); + } + + SetInstallerPropertiesFromAppxMetadata(appxMetadata, matchingInstaller, installerManifest); + } + + installers.ForEach(i => i.SignatureSha256 = signatureSha256); + + return appxMetadatas.First(); + } + catch (COMException) + { + // Binary wasn't an MSIX + return null; + } + } + + private static string RemoveInvalidCharsFromString(string value) + { + return Regex.Replace(value, InvalidCharacters, string.Empty); + } + } +}