Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion MSIXplainer.Cli/MSIXplainer.Cli.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
<RootNamespace>MSIXplainer</RootNamespace>
<AssemblyName>MSIXplainer.Cli</AssemblyName>
<ImplicitUsings>enable</ImplicitUsings>
Expand Down
26 changes: 26 additions & 0 deletions MSIXplainer.Cli/Program.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Reflection;
using System.Text;
using MSIXplainer.Models;
using MSIXplainer.Services;
Expand All @@ -17,6 +18,12 @@ static int Main(string[] args)
return 0;
}

if (args.Contains("--version") || args.Contains("-v"))
{
AnsiConsole.MarkupLine($"MSIXplainer [cyan]{Markup.Escape(GetVersion())}[/]");
return 0;
}

// Subcommands handle their own --help.
if (string.Equals(args[0], "rules", StringComparison.OrdinalIgnoreCase))
{
Expand Down Expand Up @@ -561,6 +568,8 @@ static CliOptions ParseArgs(string[] args)
static void PrintUsage()
{
AnsiConsole.Write(new FigletText("MSIXplainer").Color(Color.CornflowerBlue));
AnsiConsole.MarkupLine($" [grey]Version[/] [cyan]{Markup.Escape(GetVersion())}[/]");
AnsiConsole.WriteLine();

var table = new Table()
.Border(TableBorder.None)
Expand Down Expand Up @@ -614,6 +623,23 @@ static void PrintUsage()
AnsiConsole.Write(table);
AnsiConsole.WriteLine();
}

/// <summary>
/// Returns the assembly informational/file version, e.g. "1.0.19.0". Used by
/// the banner and `--version`. Falls back to "unknown" if reflection fails.
/// </summary>
static string GetVersion()
{
var asm = typeof(Program).Assembly;
var info = asm.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>()?.InformationalVersion;
if (!string.IsNullOrWhiteSpace(info))
{
// Strip +sha suffix that SDK appends for SourceLink
var plus = info.IndexOf('+');
return plus > 0 ? info[..plus] : info;
}
return asm.GetName().Version?.ToString() ?? "unknown";
}
}

sealed class CliOptions
Expand Down
84 changes: 84 additions & 0 deletions MSIXplainer.Core.Tests/ExtractFromManifestFileTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using MSIXplainer.Services;
using Xunit;

namespace MSIXplainer.Core.Tests;

public class ExtractFromManifestFileTests
{
private const string SampleManifest = """
<?xml version="1.0" encoding="utf-8"?>
<Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10">
<Identity Name="Contoso.Demo" Publisher="CN=Contoso" Version="2.5.0.0" ProcessorArchitecture="x64" />
<Properties>
<DisplayName>Contoso Demo</DisplayName>
<PublisherDisplayName>Contoso</PublisherDisplayName>
<Description>A demo package.</Description>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.19041.0" MaxVersionTested="10.0.22621.0" />
</Dependencies>
<Applications>
<Application Id="App" Executable="Demo.exe" EntryPoint="Windows.FullTrustApplication" />
</Applications>
</Package>
""";

[Fact]
public void ExtractFromManifestFile_ReadsLooseManifest()
{
var dir = Directory.CreateTempSubdirectory("msixplainer-manifest-test-");
try
{
var path = Path.Combine(dir.FullName, "AppxManifest.xml");
File.WriteAllText(path, SampleManifest);

var (doc, raw, info) = ManifestParserService.ExtractFromManifestFile(path);

Assert.NotNull(doc.Root);
Assert.Equal("Contoso.Demo", info.Name);
Assert.Equal("Contoso Demo", info.DisplayName);
Assert.Equal("2.5.0.0", info.Version);
Assert.Equal("x64", info.Architecture);
Assert.StartsWith("Contoso.Demo_", info.PackageFamilyName);
Assert.Equal(13, info.PackageFamilyName.Split('_')[1].Length);
Assert.Contains("<Identity", raw);
}
finally
{
dir.Delete(recursive: true);
}
}

[Fact]
public void ExtractFromManifestFile_MissingFile_Throws()
{
Assert.Throws<FileNotFoundException>(() =>
ManifestParserService.ExtractFromManifestFile(
Path.Combine(Path.GetTempPath(), "does-not-exist-" + Guid.NewGuid() + ".xml")));
}

[Fact]
public void ExtractFromManifestFile_DtdProcessing_Rejected()
{
var dir = Directory.CreateTempSubdirectory("msixplainer-manifest-dtd-");
try
{
var path = Path.Combine(dir.FullName, "AppxManifest.xml");
var malicious = """
<?xml version="1.0"?>
<!DOCTYPE Package [<!ENTITY bomb "boom">]>
<Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10">
<Identity Name="X" Publisher="CN=X" Version="1.0.0.0" ProcessorArchitecture="x64" />
</Package>
""";
File.WriteAllText(path, malicious);

Assert.ThrowsAny<System.Xml.XmlException>(() =>
ManifestParserService.ExtractFromManifestFile(path));
}
finally
{
dir.Delete(recursive: true);
}
}
}
194 changes: 194 additions & 0 deletions MSIXplainer.Core.Tests/InstalledPackageServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
using MSIXplainer.Models;
using MSIXplainer.Services;
using Xunit;

namespace MSIXplainer.Core.Tests;

public class InstalledPackageServiceTests
{
[Fact]
public void ManifestPath_BuildsFromInstallLocation()
{
var pkg = NewPackage(installLocation: Path.Combine("C:", "apps", "myapp"));
Assert.Equal(
Path.Combine("C:", "apps", "myapp", "AppxManifest.xml"),
pkg.ManifestPath);
}

[Fact]
public void ManifestPath_EmptyInstallLocation_IsNull()
{
Assert.Null(NewPackage(installLocation: "").ManifestPath);
}

[Fact]
public void List_OnNonWindows_ReturnsEmpty()
{
if (OperatingSystem.IsWindows())
return; // Only meaningful off-Windows; PackageManager is unavailable there.

Assert.Empty(InstalledPackageService.List());
}

[Fact]
public void List_OnWindows_ReturnsMainPackagesWithValidIdentity()
{
if (!OperatingSystem.IsWindows())
return;

var packages = InstalledPackageService.List();

// Don't assert specific packages — CI / dev images vary. Just verify the contract:
// no throw, valid identity fields, every PFN is in Name_PublisherHash form, and
// every row has a non-empty DisplayName (either resolved or fallen back to Name).
Assert.NotNull(packages);
foreach (var pkg in packages)
{
Assert.False(string.IsNullOrEmpty(pkg.Name), $"Name empty for {pkg.PackageFullName}");
Assert.False(string.IsNullOrEmpty(pkg.DisplayName), $"DisplayName empty for {pkg.PackageFullName}");
Assert.False(string.IsNullOrEmpty(pkg.PackageFamilyName), $"PFN empty for {pkg.Name}");
Assert.Contains("_", pkg.PackageFamilyName);
Assert.DoesNotContain("ms-resource:", pkg.DisplayName); // unresolved indirections must fall back
}
}

[Fact]
public void List_OnWindows_ReturnsSortedByDisplayName()
{
if (!OperatingSystem.IsWindows())
return;

var packages = InstalledPackageService.List();
if (packages.Count < 2) return;

var displayNames = packages.Select(p => p.DisplayName).ToList();
var sorted = displayNames.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToList();
Assert.Equal(sorted, displayNames);
}

[Fact]
public void FindByFamilyName_EmptyOrNull_ReturnsNull()
{
Assert.Null(InstalledPackageService.FindByFamilyName(""));
Assert.Null(InstalledPackageService.FindByFamilyName(" "));
Assert.Null(InstalledPackageService.FindByFamilyName(null!));
}

[Fact]
public void FindByFamilyName_OnWindows_RoundTripsKnownPackage()
{
if (!OperatingSystem.IsWindows())
return;

var first = InstalledPackageService.List().FirstOrDefault();
if (first is null) return; // No packages on this machine — skip.

var found = InstalledPackageService.FindByFamilyName(first.PackageFamilyName);
Assert.NotNull(found);
Assert.Equal(first.PackageFamilyName, found!.PackageFamilyName);
}

[Fact]
public void FindByFamilyName_UnknownPackage_ReturnsNull()
{
if (!OperatingSystem.IsWindows())
return;

Assert.Null(InstalledPackageService.FindByFamilyName(
"MSIXplainer.NonExistent.Package_0000000000000"));
}

[Fact]
public void ListWithoutIcons_OnNonWindows_ReturnsEmpty()
{
if (OperatingSystem.IsWindows())
return;

Assert.Empty(InstalledPackageService.ListWithoutIcons());
}

[Fact]
public void ListWithoutIcons_OnWindows_AllRowsHaveNullIcons()
{
if (!OperatingSystem.IsWindows())
return;

var packages = InstalledPackageService.ListWithoutIcons();
Assert.NotNull(packages);
// Fast path explicitly skips icon resolution — every row must come back with null bytes.
Assert.All(packages, p => Assert.Null(p.IconBytes));
}

[Fact]
public void ListWithoutIcons_OnWindows_ReturnsSameIdentitiesAsList()
{
if (!OperatingSystem.IsWindows())
return;

var fast = InstalledPackageService.ListWithoutIcons()
.Select(p => p.PackageFullName)
.OrderBy(n => n, StringComparer.Ordinal)
.ToList();
var full = InstalledPackageService.List()
.Select(p => p.PackageFullName)
.OrderBy(n => n, StringComparer.Ordinal)
.ToList();

Assert.Equal(full, fast);
}

[Fact]
public void ResolveIcon_AlreadyHasIcon_ReturnsSameInstance()
{
var pkg = NewPackage(installLocation: "C:\\nope") with { IconBytes = [0x01, 0x02] };
var resolved = InstalledPackageService.ResolveIcon(pkg);
Assert.Same(pkg, resolved);
}

[Fact]
public void ResolveIcon_EmptyInstallLocation_ReturnsSameInstance()
{
var pkg = NewPackage(installLocation: "");
var resolved = InstalledPackageService.ResolveIcon(pkg);
Assert.Same(pkg, resolved);
}

[Fact]
public void ResolveIcon_NonExistentFolder_ReturnsSameInstanceWithNullBytes()
{
var pkg = NewPackage(installLocation: Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()));
var resolved = InstalledPackageService.ResolveIcon(pkg);
Assert.Null(resolved.IconBytes);
}

[Fact]
public void ResolveIcon_OnWindows_PopulatesIconForRealPackage()
{
if (!OperatingSystem.IsWindows())
return;

// Pick a package whose icon-with full-pass actually resolves, then verify
// ResolveIcon arrives at the same bytes when started from the icon-less version.
var withIcons = InstalledPackageService.List();
var sample = withIcons.FirstOrDefault(p => p.IconBytes is { Length: > 0 });
if (sample is null) return; // No package on this box exposed an icon — skip.

var stripped = sample with { IconBytes = null };
var resolved = InstalledPackageService.ResolveIcon(stripped);

Assert.NotNull(resolved.IconBytes);
Assert.Equal(sample.IconBytes!.Length, resolved.IconBytes!.Length);
}

private static InstalledPackage NewPackage(string installLocation) => new()
{
Name = "X",
DisplayName = "X",
PackageFamilyName = "X_abc",
PackageFullName = "X_1.0.0.0_x64__abc",
Version = "1.0.0.0",
Publisher = "CN=X",
InstallLocation = installLocation,
Architecture = "X64"
};
}
2 changes: 1 addition & 1 deletion MSIXplainer.Core.Tests/MSIXplainer.Core.Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
Expand Down
2 changes: 1 addition & 1 deletion MSIXplainer.Core.Tests/ManifestExplainerServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public void BuildSections_IncludesApplicationSection()
var (manifest, findings) = LoadSample();
var sections = ManifestExplainerService.BuildSections(manifest, findings);

Assert.Contains(sections, s => s.Tag.StartsWith("app:"));
Assert.Contains(sections, s => s.Tag == "applications");
}

[Fact]
Expand Down
Loading
Loading