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);