From 8a41ee61a0f80521ff34df4b306efba48a28067f Mon Sep 17 00:00:00 2001 From: Stephan van Rooij <1292510+svrooij@users.noreply.github.com> Date: Mon, 23 Dec 2024 15:36:01 +0100 Subject: [PATCH] Add command to show msi info --- global.json | 5 + .../Commands/ShowMsiInfo.cs | 95 ++++++++++ .../Models/MsiInfo.cs | 21 +++ .../Svrooij.WinTuner.CmdLets.dll-Help.xml | 173 +++++++++++++++++- .../Msal/InteractiveAuthenticationProvider.cs | 2 +- src/WingetIntune/Intune/IntuneManager.cs | 9 +- .../{Internal => }/Msi/MsiDecoder.cs | 48 +++-- .../Show-MsiInfo.Tests.ps1 | 15 ++ .../{Internal => }/Msi/MsiDecoderTests.cs | 26 ++- .../WingetIntune.Tests/WingetManager.Tests.cs | 6 +- 10 files changed, 356 insertions(+), 44 deletions(-) create mode 100644 global.json create mode 100644 src/Svrooij.WinTuner.CmdLets/Commands/ShowMsiInfo.cs create mode 100644 src/Svrooij.WinTuner.CmdLets/Models/MsiInfo.cs rename src/WingetIntune/{Internal => }/Msi/MsiDecoder.cs (88%) create mode 100644 tests/WinTuner.Cmdlets.Tests/Show-MsiInfo.Tests.ps1 rename tests/WingetIntune.Tests/{Internal => }/Msi/MsiDecoderTests.cs (54%) diff --git a/global.json b/global.json new file mode 100644 index 0000000..c935468 --- /dev/null +++ b/global.json @@ -0,0 +1,5 @@ +{ + "sdk": { + "version": "8.0.104" + } +} \ No newline at end of file diff --git a/src/Svrooij.WinTuner.CmdLets/Commands/ShowMsiInfo.cs b/src/Svrooij.WinTuner.CmdLets/Commands/ShowMsiInfo.cs new file mode 100644 index 0000000..72889aa --- /dev/null +++ b/src/Svrooij.WinTuner.CmdLets/Commands/ShowMsiInfo.cs @@ -0,0 +1,95 @@ +using System; +using System.IO; +using System.Management.Automation; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Svrooij.PowerShell.DependencyInjection; +using WingetIntune; +using WingetIntune.Msi; + +namespace Svrooij.WinTuner.CmdLets.Commands; + +/// +/// Show information about an MSI file +/// Show information about an MSI file, this includes the MSI code and version. +/// +/// 100 +/// +/// Show information about an MSI file +/// Show information about an MSI file, this includes the MSI code and version. +/// Show-MsiInfo -MsiPath "C:\path\to\file.msi" +/// +/// +/// Show information about an MSI file from URL +/// Download an MSI file and show the details +/// Show-MsiInfo -MsiUrl "https://example.com/file.msi" -OutputPath "C:\path\to" +/// +[Cmdlet(VerbsCommon.Show, "MsiInfo", HelpUri = "https://wintuner.app/docs/wintuner-powershell/Show-MsiInfo", DefaultParameterSetName = nameof(MsiPath))] +[OutputType(typeof(Models.MsiInfo))] +public class ShowMsiInfo : DependencyCmdlet +{ + /// + /// Path to the MSI file + /// + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true, ParameterSetName = nameof(MsiPath))] + public string? MsiPath { get; set; } + + /// + /// URL to the MSI file + /// + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true, ParameterSetName = nameof(MsiUrl))] + public Uri? MsiUrl { get; set; } + + /// + /// Path to save the MSI file + /// + [Parameter(Mandatory = true, Position = 1, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true, ParameterSetName = nameof(MsiUrl))] + public string? OutputPath { get; set; } + + /// + /// Filename to save the MSI file, if cannot be discovered from url + /// + [Parameter(Mandatory = false, Position = 2, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true, ParameterSetName = nameof(MsiUrl))] + public string? OutputFilename { get; set; } + + [ServiceDependency] + private ILogger? logger; + + [ServiceDependency] + private IFileManager? fileManager; + + /// + public override async Task ProcessRecordAsync(CancellationToken cancellationToken) + { + if (MsiPath is not null) + { + logger?.LogInformation("Reading MSI from path: {MsiPath}", MsiPath); + } + else if (MsiUrl is not null) + { + logger?.LogInformation("Downloading MSI from URL to {OutputPath}: {MsiUrl}", MsiUrl, OutputPath); + var outputFile = Path.Combine(OutputPath!, OutputFilename ?? Path.GetFileName(MsiUrl.LocalPath)); + // The file managed does automatic chunking of the download, so it will also work for very large files. + await fileManager!.DownloadFileAsync(MsiUrl!.ToString(), outputFile, cancellationToken: cancellationToken); + MsiPath = outputFile; + } + else + { + throw new InvalidOperationException("Either MsiPath or MsiUrl must be set"); + } + + using var msiStream = new FileStream(MsiPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 4096, FileOptions.Asynchronous); + var decoder = new MsiDecoder(msiStream); + var codeFromMsi = decoder.GetCode(); + var versionFromMsi = decoder.GetVersion(); + + WriteObject(new Models.MsiInfo + { + Path = MsiPath, + ProductCode = codeFromMsi, + ProductVersion = versionFromMsi + }); + } + +} diff --git a/src/Svrooij.WinTuner.CmdLets/Models/MsiInfo.cs b/src/Svrooij.WinTuner.CmdLets/Models/MsiInfo.cs new file mode 100644 index 0000000..7d3c062 --- /dev/null +++ b/src/Svrooij.WinTuner.CmdLets/Models/MsiInfo.cs @@ -0,0 +1,21 @@ +namespace Svrooij.WinTuner.CmdLets.Models; + +/// +/// Information about an MSI file +/// +public class MsiInfo +{ + /// + /// The path to the MSI file + /// + public string? Path { get; set; } + /// + /// The product code of the MSI file + /// + public string? ProductCode { get; set; } + + /// + /// The version of the MSI file + /// + public string? ProductVersion { get; set; } +} diff --git a/src/Svrooij.WinTuner.CmdLets/Svrooij.WinTuner.CmdLets.dll-Help.xml b/src/Svrooij.WinTuner.CmdLets/Svrooij.WinTuner.CmdLets.dll-Help.xml index 7e31335..e98783b 100644 --- a/src/Svrooij.WinTuner.CmdLets/Svrooij.WinTuner.CmdLets.dll-Help.xml +++ b/src/Svrooij.WinTuner.CmdLets/Svrooij.WinTuner.CmdLets.dll-Help.xml @@ -1684,6 +1684,17 @@ You could run this on a weekly bases. None + + PartialPackage + + Creating a partial package means that the files are not zipped into the intunewin file, but are left as is. + + SwitchParameter + + SwitchParameter + + False + @@ -1823,6 +1834,17 @@ You could run this on a weekly bases. None + + PartialPackage + + Creating a partial package means that the files are not zipped into the intunewin file, but are left as is. + + SwitchParameter + + SwitchParameter + + False + @@ -1970,12 +1992,151 @@ You could run this on a weekly bases. - - - Online Version - https://wintuner.app/docs/wintuner-powershell/Search-WtWingetPackage - - + + + + Show-MsiInfo + Show + MsiInfo + + Show information about an MSI file + + + + Show information about an MSI file, this includes the MSI code and version. + + + + Show-MsiInfo + + MsiPath + + Path to the MSI file + + String + + String + + None + + + + Show-MsiInfo + + MsiUrl + + URL to the MSI file + + Uri + + Uri + + None + + + OutputPath + + Path to save the MSI file + + String + + String + + None + + + OutputFilename + + Filename to save the MSI file, if cannot be discovered from url + + String + + String + + None + + + + + + MsiPath + + Path to the MSI file + + String + + String + + None + + + MsiUrl + + URL to the MSI file + + Uri + + Uri + + None + + + OutputPath + + Path to save the MSI file + + String + + String + + None + + + OutputFilename + + Filename to save the MSI file, if cannot be discovered from url + + String + + String + + None + + + + + + Svrooij.WinTuner.CmdLets.Models.MsiInfo + + + Svrooij.WinTuner.CmdLets.Models.MsiInfo + + + + + + --------------------- Show information about an MSI file --------------------- + PS C:\> Show-MsiInfo -MsiPath "C:\path\to\file.msi" + + Show information about an MSI file, this includes the MSI code and version. + + + + + ---------------- Show information about an MSI file from URL ----------------- + PS C:\> Show-MsiInfo -MsiUrl "https://example.com/file.msi" -OutputPath "C:\path\to" + + Download an MSI file and show the details + + + + + + + Online Version + https://wintuner.app/docs/wintuner-powershell/Show-MsiInfo + + diff --git a/src/WingetIntune/Internal/Msal/InteractiveAuthenticationProvider.cs b/src/WingetIntune/Internal/Msal/InteractiveAuthenticationProvider.cs index ed536ae..54a6428 100644 --- a/src/WingetIntune/Internal/Msal/InteractiveAuthenticationProvider.cs +++ b/src/WingetIntune/Internal/Msal/InteractiveAuthenticationProvider.cs @@ -92,7 +92,7 @@ public async Task AccuireTokenAsync(IEnumerable sc : await publicClientApplication.AcquireTokenSilent(scopes, account).ExecuteAsync(cancellationToken); return authenticationResult; } - catch (MsalUiRequiredException ex) + catch (MsalUiRequiredException) { return await AcquireTokenInteractiveAsync(scopes, tenantId, account?.Username ?? userId, cancellationToken); } diff --git a/src/WingetIntune/Intune/IntuneManager.cs b/src/WingetIntune/Intune/IntuneManager.cs index cad22f2..336f214 100644 --- a/src/WingetIntune/Intune/IntuneManager.cs +++ b/src/WingetIntune/Intune/IntuneManager.cs @@ -5,18 +5,13 @@ using Microsoft.Graph.Beta.Models.ODataErrors; using Microsoft.Kiota.Abstractions.Authentication; using Microsoft.Kiota.Abstractions.Serialization; -using OpenMcdf; -using System.Collections; -using System.ComponentModel; -using System.Diagnostics; using System.Text; using System.Text.Json; -using System.Text.RegularExpressions; using WingetIntune.Commands; using WingetIntune.Graph; using WingetIntune.Interfaces; using WingetIntune.Internal.Msal; -using WingetIntune.Internal.Msi; +using WingetIntune.Msi; using WingetIntune.Intune; using WingetIntune.Models; @@ -605,7 +600,7 @@ private GraphServiceClient CreateGraphClientFromOptions(IntunePublishOptions opt } if (options.Credential is not null) { - provider = new Microsoft.Graph.Authentication.AzureIdentityAuthenticationProvider(options.Credential, null, null, RequiredScopes); + provider = new Microsoft.Graph.Authentication.AzureIdentityAuthenticationProvider(options.Credential, null, null, isCaeEnabled: false, RequiredScopes); } else if (!string.IsNullOrEmpty(options.Token)) { diff --git a/src/WingetIntune/Internal/Msi/MsiDecoder.cs b/src/WingetIntune/Msi/MsiDecoder.cs similarity index 88% rename from src/WingetIntune/Internal/Msi/MsiDecoder.cs rename to src/WingetIntune/Msi/MsiDecoder.cs index c84fc39..4552869 100644 --- a/src/WingetIntune/Internal/Msi/MsiDecoder.cs +++ b/src/WingetIntune/Msi/MsiDecoder.cs @@ -1,8 +1,12 @@ using System.Text; using OpenMcdf; -namespace WingetIntune.Internal.Msi; -internal class MsiDecoder +namespace WingetIntune.Msi; +/// +/// Cross-platform MSI Decoder using OpenMcdf +/// +/// Created by miyoyo in PR 154 +public class MsiDecoder { private int stringSize = 2; private Dictionary intToString; @@ -12,12 +16,12 @@ internal class MsiDecoder private Dictionary>> allTables; const int MSITYPE_VALID = 0x0100; - const int MSITYPE_LOCALIZABLE = 0x200; + // const int MSITYPE_LOCALIZABLE = 0x200; const int MSITYPE_STRING = 0x0800; - const int MSITYPE_NULLABLE = 0x1000; + // const int MSITYPE_NULLABLE = 0x1000; const int MSITYPE_KEY = 0x2000; - const int MSITYPE_TEMPORARY = 0x4000; - const int MSITYPE_UNKNOWN = 0x8000; + // const int MSITYPE_TEMPORARY = 0x4000; + // const int MSITYPE_UNKNOWN = 0x8000; public MsiDecoder(string filePath) { @@ -35,13 +39,33 @@ public MsiDecoder(Stream msiStream) } } - public string GetCode() + /// + /// Get the product code of the MSI file (including the braces) + /// + /// + public string? GetCode() => GetStringValue("Property", "ProductCode"); + + /// + /// Get the version of the MSI file + /// + /// + public string? GetVersion() => GetStringValue("Property", "ProductVersion"); + + /// + /// Get the value of a property in a table + /// + /// MSI details are stored in the "Property" table + /// + /// You probably want the "Value", don't know what other options you have. + /// + public string? GetStringValue(string table, string property, string value = "Value") { - return allTables["Property"].Where(row => (string)row["Property"] == "ProductCode").Select, string>(row => row["Value"].ToString()).First(); - } - public string GetVersion() - { - return allTables["Property"].Where(row => (string)row["Property"] == "ProductVersion").Select, string>(row => row["Value"].ToString()).First(); + if (!allTables.ContainsKey(table)) + { + return null; + } + + return allTables[table].Where(row => (string)row["Property"] == property).Select, string>(row => row[value].ToString()).FirstOrDefault(); } private void load(CompoundFile cf) diff --git a/tests/WinTuner.Cmdlets.Tests/Show-MsiInfo.Tests.ps1 b/tests/WinTuner.Cmdlets.Tests/Show-MsiInfo.Tests.ps1 new file mode 100644 index 0000000..7b6039b --- /dev/null +++ b/tests/WinTuner.Cmdlets.Tests/Show-MsiInfo.Tests.ps1 @@ -0,0 +1,15 @@ + +Describe 'Show-MsiInfo' { + It 'Should be available' { + $cmdlet = Get-Command -Name 'Show-MsiInfo' + $cmdlet.CommandType | Should -Be 'Cmdlet' + } + + It 'Should download and show MSI info' { + $msiUrl = 'https://download.microsoft.com/download/C/7/A/C7AAD914-A8A6-4904-88A1-29E657445D03/LAPS.x64.msi' + $msiInfo = Show-MsiInfo -MsiUrl $msiUrl -OutputPath $env:TEMP + $msiInfo | Should -Not -BeNullOrEmpty + $msiInfo.ProductCode | Should -Be '{97E2CA7B-B657-4FF7-A6DB-30ECC73E1E28}' + $msiInfo.ProductVersion | Should -Be '6.2.0.0' + } +} \ No newline at end of file diff --git a/tests/WingetIntune.Tests/Internal/Msi/MsiDecoderTests.cs b/tests/WingetIntune.Tests/Msi/MsiDecoderTests.cs similarity index 54% rename from tests/WingetIntune.Tests/Internal/Msi/MsiDecoderTests.cs rename to tests/WingetIntune.Tests/Msi/MsiDecoderTests.cs index 6dd5e6d..ca64493 100644 --- a/tests/WingetIntune.Tests/Internal/Msi/MsiDecoderTests.cs +++ b/tests/WingetIntune.Tests/Msi/MsiDecoderTests.cs @@ -1,11 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using WingetIntune.Internal.Msi; - -namespace WingetIntune.Tests.Internal.Msi; +using WingetIntune.Msi; + +namespace WingetIntune.Tests.Msi; public class MsiDecoderTests { Task msiBytes; @@ -19,22 +14,23 @@ public MsiDecoderTests() } [Fact] - public async Task GetCode_ReturnsCorrectString() { + public async Task GetCode_ReturnsCorrectString() + { var msiStream = new MemoryStream(await msiBytes); var decoder = new MsiDecoder(msiStream); - var readCode = decoder.GetCode(); - - Assert.Equal("{97E2CA7B-B657-4FF7-A6DB-30ECC73E1E28}", readCode); + var codeFromMsi = decoder.GetCode(); + Assert.Equal("{97E2CA7B-B657-4FF7-A6DB-30ECC73E1E28}", codeFromMsi); } [Fact] - public async Task GetVersion_ReturnsCorrectString() { + public async Task GetVersion_ReturnsCorrectString() + { var msiStream = new MemoryStream(await msiBytes); var decoder = new MsiDecoder(msiStream); - var readVersion = decoder.GetVersion(); + var versionFromMsi = decoder.GetVersion(); - Assert.Equal("6.2.0.0", readVersion); + Assert.Equal("6.2.0.0", versionFromMsi); } } diff --git a/tests/WingetIntune.Tests/WingetManager.Tests.cs b/tests/WingetIntune.Tests/WingetManager.Tests.cs index f273dcc..91d6533 100644 --- a/tests/WingetIntune.Tests/WingetManager.Tests.cs +++ b/tests/WingetIntune.Tests/WingetManager.Tests.cs @@ -103,11 +103,11 @@ public async Task GetPackageInfoAsync_DownloadsData_WingetResult() var processManager = Substitute.For(); var filemanagerMock = Substitute.For(); filemanagerMock.DownloadStringAsync(WingetManager.CreateManifestUri(packageId, version, null), true, Arg.Any()) - .Returns(Task.FromResult(WingetManagerTestConstants.ohMyPoshYaml)); + .Returns(WingetManagerTestConstants.ohMyPoshYaml); filemanagerMock.DownloadStringAsync(WingetManager.CreateManifestUri(packageId, version, ".installer"), true, Arg.Any()) - .Returns(Task.FromResult(WingetManagerTestConstants.ohMyPoshInstallYaml)); + .Returns(WingetManagerTestConstants.ohMyPoshInstallYaml); filemanagerMock.DownloadStringAsync(WingetManager.CreateManifestUri(packageId, version, ".locale.en-US"), true, Arg.Any()) - .Returns(Task.FromResult(WingetManagerTestConstants.ohMyPoshLocaleYaml)); + .Returns(WingetManagerTestConstants.ohMyPoshLocaleYaml); var wingetManager = new WingetManager(logger, processManager, filemanagerMock); var info = await wingetManager.GetPackageInfoAsync(packageId, version, source);