This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.Tests;
|
||||
|
||||
public class ChocolateyAnalyzerPluginTests
|
||||
{
|
||||
[Fact]
|
||||
public void Name_ReturnsCorrectPluginName()
|
||||
{
|
||||
// Arrange
|
||||
var plugin = new ChocolateyAnalyzerPlugin();
|
||||
|
||||
// Act
|
||||
var name = plugin.Name;
|
||||
|
||||
// Assert
|
||||
Assert.Equal("StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey", name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAvailable_WithValidServiceProvider_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var plugin = new ChocolateyAnalyzerPlugin();
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton<ILoggerFactory, NullLoggerFactory>()
|
||||
.BuildServiceProvider();
|
||||
|
||||
// Act
|
||||
var result = plugin.IsAvailable(services);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAvailable_WithNullServiceProvider_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var plugin = new ChocolateyAnalyzerPlugin();
|
||||
|
||||
// Act
|
||||
var result = plugin.IsAvailable(null!);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateAnalyzer_WithValidServiceProvider_ReturnsAnalyzer()
|
||||
{
|
||||
// Arrange
|
||||
var plugin = new ChocolateyAnalyzerPlugin();
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton<ILoggerFactory, NullLoggerFactory>()
|
||||
.BuildServiceProvider();
|
||||
|
||||
// Act
|
||||
var analyzer = plugin.CreateAnalyzer(services);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(analyzer);
|
||||
Assert.IsType<ChocolateyPackageAnalyzer>(analyzer);
|
||||
Assert.Equal("windows-chocolatey", analyzer.AnalyzerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateAnalyzer_WithNullServiceProvider_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var plugin = new ChocolateyAnalyzerPlugin();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => plugin.CreateAnalyzer(null!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.Tests;
|
||||
|
||||
public class ChocolateyPackageAnalyzerTests
|
||||
{
|
||||
private readonly ChocolateyPackageAnalyzer _analyzer;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public ChocolateyPackageAnalyzerTests()
|
||||
{
|
||||
_logger = NullLoggerFactory.Instance.CreateLogger<ChocolateyPackageAnalyzer>();
|
||||
_analyzer = new ChocolateyPackageAnalyzer((ILogger<ChocolateyPackageAnalyzer>)_logger);
|
||||
}
|
||||
|
||||
private OSPackageAnalyzerContext CreateContext(string rootPath)
|
||||
{
|
||||
return new OSPackageAnalyzerContext(
|
||||
rootPath,
|
||||
workspacePath: null,
|
||||
TimeProvider.System,
|
||||
_logger);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzerId_ReturnsCorrectValue()
|
||||
{
|
||||
Assert.Equal("windows-chocolatey", _analyzer.AnalyzerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_WithNoChocolateyDirectory_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
var context = CreateContext(tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result.Packages);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_WithEmptyChocolateyLib_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib");
|
||||
Directory.CreateDirectory(chocoLib);
|
||||
|
||||
try
|
||||
{
|
||||
var context = CreateContext(tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result.Packages);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_WithNuspecFile_ReturnsPackageRecord()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib");
|
||||
var packageDir = Path.Combine(chocoLib, "git.2.42.0");
|
||||
Directory.CreateDirectory(packageDir);
|
||||
|
||||
CreateNuspecFile(packageDir, "git", "2.42.0", "Git", "Git Authors", "Git for Windows");
|
||||
|
||||
try
|
||||
{
|
||||
var context = CreateContext(tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result.Packages);
|
||||
var record = result.Packages[0];
|
||||
|
||||
Assert.Equal("Git", record.Name);
|
||||
Assert.Equal("2.42.0", record.Version);
|
||||
Assert.Equal("pkg:chocolatey/git@2.42.0", record.PackageUrl);
|
||||
Assert.Equal(PackageEvidenceSource.WindowsChocolatey, record.EvidenceSource);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_WithMultiplePackages_ReturnsAllRecords()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib");
|
||||
|
||||
var package1Dir = Path.Combine(chocoLib, "git.2.42.0");
|
||||
var package2Dir = Path.Combine(chocoLib, "nodejs.20.10.0");
|
||||
var package3Dir = Path.Combine(chocoLib, "7zip.23.01");
|
||||
|
||||
Directory.CreateDirectory(package1Dir);
|
||||
Directory.CreateDirectory(package2Dir);
|
||||
Directory.CreateDirectory(package3Dir);
|
||||
|
||||
CreateNuspecFile(package1Dir, "git", "2.42.0", "Git", "Git Authors", "Git for Windows");
|
||||
CreateNuspecFile(package2Dir, "nodejs", "20.10.0", "Node.js", "Node.js Foundation", "Node.js runtime");
|
||||
CreateNuspecFile(package3Dir, "7zip", "23.01", "7-Zip", "Igor Pavlov", "File archiver");
|
||||
|
||||
try
|
||||
{
|
||||
var context = CreateContext(tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, result.Packages.Count);
|
||||
|
||||
var git = result.Packages.FirstOrDefault(r => r.PackageUrl.Contains("git"));
|
||||
Assert.NotNull(git);
|
||||
Assert.Equal("2.42.0", git.Version);
|
||||
|
||||
var node = result.Packages.FirstOrDefault(r => r.PackageUrl.Contains("nodejs"));
|
||||
Assert.NotNull(node);
|
||||
Assert.Equal("20.10.0", node.Version);
|
||||
|
||||
var sevenZip = result.Packages.FirstOrDefault(r => r.PackageUrl.Contains("7zip"));
|
||||
Assert.NotNull(sevenZip);
|
||||
Assert.Equal("23.01", sevenZip.Version);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_ExtractsVendorMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib");
|
||||
var packageDir = Path.Combine(chocoLib, "vscode.1.85.0");
|
||||
Directory.CreateDirectory(packageDir);
|
||||
|
||||
CreateNuspecFile(packageDir, "vscode", "1.85.0", "Visual Studio Code",
|
||||
"Microsoft", "Visual Studio Code editor",
|
||||
"https://code.visualstudio.com",
|
||||
"https://code.visualstudio.com/license");
|
||||
|
||||
try
|
||||
{
|
||||
var context = CreateContext(tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result.Packages);
|
||||
var record = result.Packages[0];
|
||||
|
||||
Assert.Equal("vscode", record.VendorMetadata["choco:id"]);
|
||||
Assert.Equal("1.85.0", record.VendorMetadata["choco:version"]);
|
||||
Assert.Equal("Visual Studio Code", record.VendorMetadata["choco:title"]);
|
||||
Assert.Equal("Microsoft", record.VendorMetadata["choco:authors"]);
|
||||
Assert.Equal("https://code.visualstudio.com", record.VendorMetadata["choco:project_url"]);
|
||||
Assert.Equal("https://code.visualstudio.com/license", record.VendorMetadata["choco:license_url"]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_WithInstallScript_ComputesHash()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib");
|
||||
var packageDir = Path.Combine(chocoLib, "git.2.42.0");
|
||||
var toolsDir = Path.Combine(packageDir, "tools");
|
||||
Directory.CreateDirectory(toolsDir);
|
||||
|
||||
CreateNuspecFile(packageDir, "git", "2.42.0", "Git", "Git Authors", "Git for Windows");
|
||||
File.WriteAllText(Path.Combine(toolsDir, "chocolateyinstall.ps1"), "Write-Host 'Installing Git'");
|
||||
|
||||
try
|
||||
{
|
||||
var context = CreateContext(tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result.Packages);
|
||||
var record = result.Packages[0];
|
||||
|
||||
Assert.True(record.VendorMetadata.ContainsKey("choco:install_script_hash"));
|
||||
Assert.StartsWith("sha256:", record.VendorMetadata["choco:install_script_hash"]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_FallsBackToDirectoryParsing_WhenNoNuspec()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib");
|
||||
var packageDir = Path.Combine(chocoLib, "python.3.12.0");
|
||||
Directory.CreateDirectory(packageDir);
|
||||
|
||||
// Create a file but no nuspec
|
||||
File.WriteAllText(Path.Combine(packageDir, "dummy.txt"), "placeholder");
|
||||
|
||||
try
|
||||
{
|
||||
var context = CreateContext(tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result.Packages);
|
||||
var record = result.Packages[0];
|
||||
|
||||
Assert.Equal("python", record.Name);
|
||||
Assert.Equal("3.12.0", record.Version);
|
||||
Assert.Equal("pkg:chocolatey/python@3.12.0", record.PackageUrl);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_IncludesFileEvidence()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib");
|
||||
var packageDir = Path.Combine(chocoLib, "git.2.42.0");
|
||||
var toolsDir = Path.Combine(packageDir, "tools");
|
||||
Directory.CreateDirectory(toolsDir);
|
||||
|
||||
CreateNuspecFile(packageDir, "git", "2.42.0", "Git", "Git Authors", "Git for Windows");
|
||||
File.WriteAllText(Path.Combine(toolsDir, "chocolateyinstall.ps1"), "Write-Host 'Installing'");
|
||||
File.WriteAllText(Path.Combine(toolsDir, "helper.bat"), "@echo off");
|
||||
File.WriteAllText(Path.Combine(packageDir, "config.json"), "{}");
|
||||
|
||||
try
|
||||
{
|
||||
var context = CreateContext(tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result.Packages);
|
||||
var record = result.Packages[0];
|
||||
|
||||
// Should include key files (ps1, bat, json, nuspec)
|
||||
Assert.Contains(record.Files, f => f.Path.EndsWith(".ps1"));
|
||||
Assert.Contains(record.Files, f => f.Path.EndsWith(".bat"));
|
||||
Assert.Contains(record.Files, f => f.Path.EndsWith(".json"));
|
||||
Assert.Contains(record.Files, f => f.Path.EndsWith(".nuspec"));
|
||||
|
||||
// Config file should be marked as config
|
||||
var configFile = record.Files.FirstOrDefault(f => f.Path.EndsWith(".json"));
|
||||
Assert.NotNull(configFile);
|
||||
Assert.True(configFile.IsConfigFile);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_ResultsAreSortedDeterministically()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib");
|
||||
|
||||
// Create packages in random order
|
||||
var zetaDir = Path.Combine(chocoLib, "zeta.1.0.0");
|
||||
var alphaDir = Path.Combine(chocoLib, "alpha.1.0.0");
|
||||
var midDir = Path.Combine(chocoLib, "mid.1.0.0");
|
||||
|
||||
Directory.CreateDirectory(zetaDir);
|
||||
Directory.CreateDirectory(alphaDir);
|
||||
Directory.CreateDirectory(midDir);
|
||||
|
||||
CreateNuspecFile(zetaDir, "zeta", "1.0.0", "Zeta", "Author", "Zeta package");
|
||||
CreateNuspecFile(alphaDir, "alpha", "1.0.0", "Alpha", "Author", "Alpha package");
|
||||
CreateNuspecFile(midDir, "mid", "1.0.0", "Mid", "Author", "Mid package");
|
||||
|
||||
try
|
||||
{
|
||||
var context = CreateContext(tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert - Results should be sorted by PURL
|
||||
Assert.Equal(3, result.Packages.Count);
|
||||
Assert.Equal("Alpha", result.Packages[0].Name);
|
||||
Assert.Equal("Mid", result.Packages[1].Name);
|
||||
Assert.Equal("Zeta", result.Packages[2].Name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_SkipsHiddenDirectories()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib");
|
||||
|
||||
var validDir = Path.Combine(chocoLib, "git.2.42.0");
|
||||
var hiddenDir = Path.Combine(chocoLib, ".hidden");
|
||||
|
||||
Directory.CreateDirectory(validDir);
|
||||
Directory.CreateDirectory(hiddenDir);
|
||||
|
||||
CreateNuspecFile(validDir, "git", "2.42.0", "Git", "Author", "Git");
|
||||
CreateNuspecFile(hiddenDir, "hidden", "1.0.0", "Hidden", "Author", "Hidden package");
|
||||
|
||||
try
|
||||
{
|
||||
var context = CreateContext(tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert - Only valid package should be returned
|
||||
Assert.Single(result.Packages);
|
||||
Assert.Equal("Git", result.Packages[0].Name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_HandlesLowerCaseChocolateyPath()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var chocoLib = Path.Combine(tempDir, "ProgramData", "chocolatey", "lib"); // lowercase
|
||||
var packageDir = Path.Combine(chocoLib, "git.2.42.0");
|
||||
Directory.CreateDirectory(packageDir);
|
||||
|
||||
CreateNuspecFile(packageDir, "git", "2.42.0", "Git", "Author", "Git");
|
||||
|
||||
try
|
||||
{
|
||||
var context = CreateContext(tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result.Packages);
|
||||
Assert.Equal("Git", result.Packages[0].Name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_TruncatesLongDescription()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib");
|
||||
var packageDir = Path.Combine(chocoLib, "longdesc.1.0.0");
|
||||
Directory.CreateDirectory(packageDir);
|
||||
|
||||
var longDescription = new string('A', 500);
|
||||
CreateNuspecFile(packageDir, "longdesc", "1.0.0", "LongDesc", "Author", longDescription);
|
||||
|
||||
try
|
||||
{
|
||||
var context = CreateContext(tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result.Packages);
|
||||
var record = result.Packages[0];
|
||||
|
||||
var description = record.VendorMetadata["choco:description"];
|
||||
Assert.True(description!.Length <= 200);
|
||||
Assert.EndsWith("...", description);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_WithCancellation_ThrowsOperationCanceledException()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib");
|
||||
var packageDir = Path.Combine(chocoLib, "git.2.42.0");
|
||||
Directory.CreateDirectory(packageDir);
|
||||
|
||||
CreateNuspecFile(packageDir, "git", "2.42.0", "Git", "Author", "Git");
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
try
|
||||
{
|
||||
var context = CreateContext(tempDir);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(
|
||||
async () => await _analyzer.AnalyzeAsync(context, cts.Token));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CreateNuspecFile(
|
||||
string packageDir,
|
||||
string id,
|
||||
string version,
|
||||
string title,
|
||||
string authors,
|
||||
string description,
|
||||
string? projectUrl = null,
|
||||
string? licenseUrl = null)
|
||||
{
|
||||
var nuspecPath = Path.Combine(packageDir, $"{id}.nuspec");
|
||||
|
||||
var projectUrlElement = projectUrl is not null ? $"<projectUrl>{projectUrl}</projectUrl>" : "";
|
||||
var licenseUrlElement = licenseUrl is not null ? $"<licenseUrl>{licenseUrl}</licenseUrl>" : "";
|
||||
|
||||
var content = $@"<?xml version=""1.0"" encoding=""utf-8""?>
|
||||
<package xmlns=""http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd"">
|
||||
<metadata>
|
||||
<id>{id}</id>
|
||||
<version>{version}</version>
|
||||
<title>{title}</title>
|
||||
<authors>{authors}</authors>
|
||||
<description>{description}</description>
|
||||
{projectUrlElement}
|
||||
{licenseUrlElement}
|
||||
</metadata>
|
||||
</package>";
|
||||
|
||||
File.WriteAllText(nuspecPath, content);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
using StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.Tests;
|
||||
|
||||
public class NuspecParserTests
|
||||
{
|
||||
private readonly NuspecParser _parser = new();
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithValidNuspec_ReturnsMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "nuspec-parser-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
var nuspecPath = Path.Combine(tempDir, "git.nuspec");
|
||||
File.WriteAllText(nuspecPath, @"<?xml version=""1.0"" encoding=""utf-8""?>
|
||||
<package xmlns=""http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd"">
|
||||
<metadata>
|
||||
<id>git</id>
|
||||
<version>2.42.0</version>
|
||||
<title>Git</title>
|
||||
<authors>Git Authors</authors>
|
||||
<description>Git for Windows</description>
|
||||
<projectUrl>https://git-scm.com</projectUrl>
|
||||
<licenseUrl>https://opensource.org/licenses/MIT</licenseUrl>
|
||||
</metadata>
|
||||
</package>");
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse(nuspecPath, tempDir);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("git", result.Id);
|
||||
Assert.Equal("2.42.0", result.Version);
|
||||
Assert.Equal("Git", result.Title);
|
||||
Assert.Equal("Git Authors", result.Authors);
|
||||
Assert.Equal("Git for Windows", result.Description);
|
||||
Assert.Equal("https://git-scm.com", result.ProjectUrl);
|
||||
Assert.Equal("https://opensource.org/licenses/MIT", result.LicenseUrl);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithOldNamespace_ReturnsMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "nuspec-parser-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
var nuspecPath = Path.Combine(tempDir, "git.nuspec");
|
||||
File.WriteAllText(nuspecPath, @"<?xml version=""1.0"" encoding=""utf-8""?>
|
||||
<package xmlns=""http://schemas.microsoft.com/packaging/2010/07/nuspec.xsd"">
|
||||
<metadata>
|
||||
<id>git</id>
|
||||
<version>2.42.0</version>
|
||||
</metadata>
|
||||
</package>");
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse(nuspecPath, tempDir);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("git", result.Id);
|
||||
Assert.Equal("2.42.0", result.Version);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithOld2011Namespace_ReturnsMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "nuspec-parser-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
var nuspecPath = Path.Combine(tempDir, "git.nuspec");
|
||||
File.WriteAllText(nuspecPath, @"<?xml version=""1.0"" encoding=""utf-8""?>
|
||||
<package xmlns=""http://schemas.microsoft.com/packaging/2011/08/nuspec.xsd"">
|
||||
<metadata>
|
||||
<id>git</id>
|
||||
<version>2.42.0</version>
|
||||
</metadata>
|
||||
</package>");
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse(nuspecPath, tempDir);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("git", result.Id);
|
||||
Assert.Equal("2.42.0", result.Version);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithNoNamespace_ReturnsMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "nuspec-parser-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
var nuspecPath = Path.Combine(tempDir, "git.nuspec");
|
||||
File.WriteAllText(nuspecPath, @"<?xml version=""1.0"" encoding=""utf-8""?>
|
||||
<package>
|
||||
<metadata>
|
||||
<id>git</id>
|
||||
<version>2.42.0</version>
|
||||
</metadata>
|
||||
</package>");
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse(nuspecPath, tempDir);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("git", result.Id);
|
||||
Assert.Equal("2.42.0", result.Version);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithMissingId_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "nuspec-parser-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
var nuspecPath = Path.Combine(tempDir, "invalid.nuspec");
|
||||
File.WriteAllText(nuspecPath, @"<?xml version=""1.0""?>
|
||||
<package xmlns=""http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd"">
|
||||
<metadata>
|
||||
<version>1.0.0</version>
|
||||
</metadata>
|
||||
</package>");
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse(nuspecPath, tempDir);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithMissingVersion_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "nuspec-parser-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
var nuspecPath = Path.Combine(tempDir, "invalid.nuspec");
|
||||
File.WriteAllText(nuspecPath, @"<?xml version=""1.0""?>
|
||||
<package xmlns=""http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd"">
|
||||
<metadata>
|
||||
<id>test</id>
|
||||
</metadata>
|
||||
</package>");
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse(nuspecPath, tempDir);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithInvalidXml_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "nuspec-parser-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
var nuspecPath = Path.Combine(tempDir, "invalid.nuspec");
|
||||
File.WriteAllText(nuspecPath, "not valid xml");
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse(nuspecPath, tempDir);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithNonExistentFile_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse("/nonexistent/path/file.nuspec", "/nonexistent");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithNullPath_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse(null!, "/some/path");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithEmptyPath_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse("", "/some/path");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithWhitespacePath_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse(" ", "/some/path");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ComputesInstallScriptHash_FromToolsDirectory()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "nuspec-parser-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var toolsDir = Path.Combine(tempDir, "tools");
|
||||
Directory.CreateDirectory(toolsDir);
|
||||
|
||||
var nuspecPath = Path.Combine(tempDir, "git.nuspec");
|
||||
File.WriteAllText(nuspecPath, @"<?xml version=""1.0""?>
|
||||
<package xmlns=""http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd"">
|
||||
<metadata>
|
||||
<id>git</id>
|
||||
<version>2.42.0</version>
|
||||
</metadata>
|
||||
</package>");
|
||||
|
||||
File.WriteAllText(Path.Combine(toolsDir, "chocolateyinstall.ps1"), "Write-Host 'Installing'");
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse(nuspecPath, tempDir);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.InstallScriptHash);
|
||||
Assert.StartsWith("sha256:", result.InstallScriptHash);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ComputesInstallScriptHash_FromRootDirectory()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "nuspec-parser-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
var nuspecPath = Path.Combine(tempDir, "git.nuspec");
|
||||
File.WriteAllText(nuspecPath, @"<?xml version=""1.0""?>
|
||||
<package xmlns=""http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd"">
|
||||
<metadata>
|
||||
<id>git</id>
|
||||
<version>2.42.0</version>
|
||||
</metadata>
|
||||
</package>");
|
||||
|
||||
File.WriteAllText(Path.Combine(tempDir, "chocolateyinstall.ps1"), "Write-Host 'Installing'");
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse(nuspecPath, tempDir);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.InstallScriptHash);
|
||||
Assert.StartsWith("sha256:", result.InstallScriptHash);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_EnumeratesInstalledFiles()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "nuspec-parser-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var toolsDir = Path.Combine(tempDir, "tools");
|
||||
Directory.CreateDirectory(toolsDir);
|
||||
|
||||
var nuspecPath = Path.Combine(tempDir, "git.nuspec");
|
||||
File.WriteAllText(nuspecPath, @"<?xml version=""1.0""?>
|
||||
<package xmlns=""http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd"">
|
||||
<metadata>
|
||||
<id>git</id>
|
||||
<version>2.42.0</version>
|
||||
</metadata>
|
||||
</package>");
|
||||
|
||||
File.WriteAllText(Path.Combine(toolsDir, "chocolateyinstall.ps1"), "Write-Host 'Installing'");
|
||||
File.WriteAllText(Path.Combine(tempDir, "config.json"), "{}");
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse(nuspecPath, tempDir);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Contains(result.InstalledFiles, f => f.Contains("chocolateyinstall.ps1"));
|
||||
Assert.Contains(result.InstalledFiles, f => f.Contains("config.json"));
|
||||
Assert.Contains(result.InstalledFiles, f => f.Contains("git.nuspec"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("git.2.42.0", "git", "2.42.0")]
|
||||
[InlineData("nodejs.20.10.0", "nodejs", "20.10.0")]
|
||||
[InlineData("7zip.23.01", "7zip", "23.01")]
|
||||
[InlineData("python.3.12.0", "python", "3.12.0")]
|
||||
[InlineData("dotnet-sdk.8.0.100-rc.1", "dotnet-sdk", "8.0.100-rc.1")]
|
||||
[InlineData("Microsoft.WindowsTerminal.1.18.3181.0", "Microsoft.WindowsTerminal", "1.18.3181.0")]
|
||||
public void ParsePackageDirectory_WithValidFormat_ReturnsIdAndVersion(string dirName, string expectedId, string expectedVersion)
|
||||
{
|
||||
// Act
|
||||
var result = NuspecParser.ParsePackageDirectory(dirName);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(expectedId, result.Value.Id);
|
||||
Assert.Equal(expectedVersion, result.Value.Version);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("git")]
|
||||
[InlineData("no-version-here")]
|
||||
public void ParsePackageDirectory_WithInvalidFormat_ReturnsNull(string? dirName)
|
||||
{
|
||||
// Act
|
||||
var result = NuspecParser.ParsePackageDirectory(dirName!);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.OS/StellaOps.Scanner.Analyzers.OS.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="Fixtures\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,241 @@
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.OS.Windows.Msi.Tests;
|
||||
|
||||
public class MsiDatabaseParserTests
|
||||
{
|
||||
private readonly MsiDatabaseParser _parser = new();
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithValidMsiFile_ExtractsMetadata()
|
||||
{
|
||||
// Arrange - Create a minimal valid OLE compound document
|
||||
var tempFile = CreateMinimalMsiFile("TestProduct-1.2.3.msi");
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse(tempFile, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("TestProduct", result.ProductName);
|
||||
Assert.Equal("1.2.3", result.ProductVersion);
|
||||
Assert.Equal(tempFile, result.FilePath);
|
||||
Assert.True(result.FileSize > 0);
|
||||
Assert.StartsWith("sha256:", result.FileHash);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tempFile))
|
||||
{
|
||||
File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithVersionedFilename_ExtractsVersionFromName()
|
||||
{
|
||||
// Arrange
|
||||
var tempFile = CreateMinimalMsiFile("SomeProduct_v2.0.1.msi");
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse(tempFile, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("SomeProduct", result.ProductName);
|
||||
Assert.Equal("2.0.1", result.ProductVersion);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tempFile))
|
||||
{
|
||||
File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithSpaceVersionedFilename_ExtractsVersionFromName()
|
||||
{
|
||||
// Arrange
|
||||
var tempFile = CreateMinimalMsiFile("Application Setup 3.0.0.msi");
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse(tempFile, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("Application Setup", result.ProductName);
|
||||
Assert.Equal("3.0.0", result.ProductVersion);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tempFile))
|
||||
{
|
||||
File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithUnversionedFilename_UsesDefaultVersion()
|
||||
{
|
||||
// Arrange
|
||||
var tempFile = CreateMinimalMsiFile("SimpleInstaller.msi");
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse(tempFile, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("SimpleInstaller", result.ProductName);
|
||||
Assert.Equal("0.0.0", result.ProductVersion);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tempFile))
|
||||
{
|
||||
File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithNonExistentFile_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse("/nonexistent/path/file.msi", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithInvalidMsiFile_ReturnsNull()
|
||||
{
|
||||
// Arrange - Create a file with invalid content
|
||||
var tempFile = Path.GetTempFileName();
|
||||
File.WriteAllText(tempFile, "Not an MSI file");
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse(tempFile, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithEmptyPath_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse(string.Empty, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithNullPath_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse(null!, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Product-1.0.msi", "Product", "1.0")]
|
||||
[InlineData("Product-1.0.0.msi", "Product", "1.0.0")]
|
||||
[InlineData("Product-1.0.0.1.msi", "Product", "1.0.0.1")]
|
||||
[InlineData("My-Product_v5.2.msi", "My-Product", "5.2")]
|
||||
[InlineData("App 10.0.msi", "App", "10.0")]
|
||||
public void Parse_WithVariousFilenamePatterns_ExtractsCorrectNameAndVersion(
|
||||
string filename, string expectedName, string expectedVersion)
|
||||
{
|
||||
// Arrange
|
||||
var tempFile = CreateMinimalMsiFile(filename);
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse(tempFile, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(expectedName, result.ProductName);
|
||||
Assert.Equal(expectedVersion, result.ProductVersion);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tempFile))
|
||||
{
|
||||
File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a minimal valid OLE compound document (MSI) file for testing.
|
||||
/// </summary>
|
||||
private static string CreateMinimalMsiFile(string filename)
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "msi-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
var filePath = Path.Combine(tempDir, filename);
|
||||
|
||||
// OLE compound document header (512 bytes minimum)
|
||||
// https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-cfb/
|
||||
var header = new byte[512];
|
||||
|
||||
// Magic number: D0 CF 11 E0 A1 B1 1A E1
|
||||
header[0] = 0xD0;
|
||||
header[1] = 0xCF;
|
||||
header[2] = 0x11;
|
||||
header[3] = 0xE0;
|
||||
header[4] = 0xA1;
|
||||
header[5] = 0xB1;
|
||||
header[6] = 0x1A;
|
||||
header[7] = 0xE1;
|
||||
|
||||
// Minor version (typically 0x003E)
|
||||
header[0x18] = 0x3E;
|
||||
header[0x19] = 0x00;
|
||||
|
||||
// Major version (3 for sector size 512, 4 for sector size 4096)
|
||||
header[0x1A] = 0x03;
|
||||
header[0x1B] = 0x00;
|
||||
|
||||
// Byte order (0xFFFE = little endian)
|
||||
header[0x1C] = 0xFE;
|
||||
header[0x1D] = 0xFF;
|
||||
|
||||
// Sector size power (9 = 512 bytes)
|
||||
header[0x1E] = 0x09;
|
||||
header[0x1F] = 0x00;
|
||||
|
||||
// Mini sector size power (6 = 64 bytes)
|
||||
header[0x20] = 0x06;
|
||||
header[0x21] = 0x00;
|
||||
|
||||
File.WriteAllBytes(filePath, header);
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.Analyzers.OS.Windows.Msi;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.OS.Windows.Msi.Tests;
|
||||
|
||||
public class MsiPackageAnalyzerTests
|
||||
{
|
||||
private readonly MsiPackageAnalyzer _analyzer;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public MsiPackageAnalyzerTests()
|
||||
{
|
||||
_logger = NullLoggerFactory.Instance.CreateLogger<MsiPackageAnalyzer>();
|
||||
_analyzer = new MsiPackageAnalyzer((ILogger<MsiPackageAnalyzer>)_logger);
|
||||
}
|
||||
|
||||
private OSPackageAnalyzerContext CreateContext(string rootPath)
|
||||
{
|
||||
return new OSPackageAnalyzerContext(
|
||||
rootPath,
|
||||
workspacePath: null,
|
||||
TimeProvider.System,
|
||||
_logger);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzerId_ReturnsCorrectValue()
|
||||
{
|
||||
Assert.Equal("windows-msi", _analyzer.AnalyzerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_WithNoMsiDirectory_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "msi-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
var context = CreateContext(tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result.Packages);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_WithMsiFiles_ReturnsPackageRecords()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "msi-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var installerDir = Path.Combine(tempDir, "Windows", "Installer");
|
||||
Directory.CreateDirectory(installerDir);
|
||||
|
||||
// Create test MSI files
|
||||
CreateMinimalMsiFile(Path.Combine(installerDir, "TestApp-1.0.0.msi"));
|
||||
CreateMinimalMsiFile(Path.Combine(installerDir, "AnotherApp-2.5.0.msi"));
|
||||
|
||||
try
|
||||
{
|
||||
var context = CreateContext(tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Packages.Count);
|
||||
|
||||
var testApp = result.Packages.FirstOrDefault(r => r.Name == "TestApp");
|
||||
Assert.NotNull(testApp);
|
||||
Assert.Equal("1.0.0", testApp.Version);
|
||||
Assert.Equal(PackageEvidenceSource.WindowsMsi, testApp.EvidenceSource);
|
||||
Assert.StartsWith("pkg:generic/windows-msi/testapp@1.0.0", testApp.PackageUrl);
|
||||
Assert.True(testApp.VendorMetadata.ContainsKey("msi:file_path"));
|
||||
|
||||
var anotherApp = result.Packages.FirstOrDefault(r => r.Name == "AnotherApp");
|
||||
Assert.NotNull(anotherApp);
|
||||
Assert.Equal("2.5.0", anotherApp.Version);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_WithNestedMsiFiles_DiscoversMsisRecursively()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "msi-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var cacheDir = Path.Combine(tempDir, "ProgramData", "Package Cache", "subfolder");
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
|
||||
CreateMinimalMsiFile(Path.Combine(cacheDir, "NestedApp-3.0.0.msi"));
|
||||
|
||||
try
|
||||
{
|
||||
var context = CreateContext(tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result.Packages);
|
||||
Assert.Equal("NestedApp", result.Packages[0].Name);
|
||||
Assert.Equal("3.0.0", result.Packages[0].Version);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_WithUserAppDataCache_ScansMsisInUserDirectories()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "msi-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var userCacheDir = Path.Combine(tempDir, "Users", "testuser", "AppData", "Local", "Package Cache");
|
||||
Directory.CreateDirectory(userCacheDir);
|
||||
|
||||
CreateMinimalMsiFile(Path.Combine(userCacheDir, "UserApp-1.0.0.msi"));
|
||||
|
||||
try
|
||||
{
|
||||
var context = CreateContext(tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result.Packages);
|
||||
Assert.Equal("UserApp", result.Packages[0].Name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_WithInvalidMsiFile_SkipsInvalidFile()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "msi-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var installerDir = Path.Combine(tempDir, "Windows", "Installer");
|
||||
Directory.CreateDirectory(installerDir);
|
||||
|
||||
// Create invalid MSI file
|
||||
File.WriteAllText(Path.Combine(installerDir, "Invalid.msi"), "Not an MSI");
|
||||
|
||||
// Create valid MSI file
|
||||
CreateMinimalMsiFile(Path.Combine(installerDir, "Valid-1.0.0.msi"));
|
||||
|
||||
try
|
||||
{
|
||||
var context = CreateContext(tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert - Only valid MSI should be returned
|
||||
Assert.Single(result.Packages);
|
||||
Assert.Equal("Valid", result.Packages[0].Name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_ResultsAreSortedDeterministically()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "msi-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var installerDir = Path.Combine(tempDir, "Windows", "Installer");
|
||||
Directory.CreateDirectory(installerDir);
|
||||
|
||||
// Create MSI files in random order
|
||||
CreateMinimalMsiFile(Path.Combine(installerDir, "ZetaApp-1.0.0.msi"));
|
||||
CreateMinimalMsiFile(Path.Combine(installerDir, "AlphaApp-1.0.0.msi"));
|
||||
CreateMinimalMsiFile(Path.Combine(installerDir, "MidApp-1.0.0.msi"));
|
||||
|
||||
try
|
||||
{
|
||||
var context = CreateContext(tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert - Results should be sorted
|
||||
Assert.Equal(3, result.Packages.Count);
|
||||
Assert.Equal("AlphaApp", result.Packages[0].Name);
|
||||
Assert.Equal("MidApp", result.Packages[1].Name);
|
||||
Assert.Equal("ZetaApp", result.Packages[2].Name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_WithDuplicateMsiFiles_DeduplicatesByPath()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "msi-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var installerDir = Path.Combine(tempDir, "Windows", "Installer");
|
||||
Directory.CreateDirectory(installerDir);
|
||||
|
||||
var msiPath = Path.Combine(installerDir, "TestApp-1.0.0.msi");
|
||||
CreateMinimalMsiFile(msiPath);
|
||||
|
||||
try
|
||||
{
|
||||
var context = CreateContext(tempDir);
|
||||
|
||||
// Act - Execute twice to ensure no duplicates
|
||||
var result1 = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
var result2 = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result1.Packages);
|
||||
Assert.Single(result2.Packages);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_SetsCorrectFileEvidence()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "msi-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var installerDir = Path.Combine(tempDir, "Windows", "Installer");
|
||||
Directory.CreateDirectory(installerDir);
|
||||
|
||||
CreateMinimalMsiFile(Path.Combine(installerDir, "TestApp-1.0.0.msi"));
|
||||
|
||||
try
|
||||
{
|
||||
var context = CreateContext(tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result.Packages);
|
||||
var record = result.Packages[0];
|
||||
Assert.Single(record.Files);
|
||||
|
||||
var file = record.Files[0];
|
||||
Assert.Contains("TestApp-1.0.0.msi", file.Path);
|
||||
Assert.NotNull(file.Sha256);
|
||||
Assert.StartsWith("sha256:", file.Sha256);
|
||||
Assert.True(file.SizeBytes > 0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a minimal valid OLE compound document (MSI) file for testing.
|
||||
/// </summary>
|
||||
private static void CreateMinimalMsiFile(string filePath)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(filePath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
// OLE compound document header (512 bytes minimum)
|
||||
var header = new byte[512];
|
||||
|
||||
// Magic number: D0 CF 11 E0 A1 B1 1A E1
|
||||
header[0] = 0xD0;
|
||||
header[1] = 0xCF;
|
||||
header[2] = 0x11;
|
||||
header[3] = 0xE0;
|
||||
header[4] = 0xA1;
|
||||
header[5] = 0xB1;
|
||||
header[6] = 0x1A;
|
||||
header[7] = 0xE1;
|
||||
|
||||
// Minor version
|
||||
header[0x18] = 0x3E;
|
||||
header[0x19] = 0x00;
|
||||
|
||||
// Major version
|
||||
header[0x1A] = 0x03;
|
||||
header[0x1B] = 0x00;
|
||||
|
||||
// Byte order (little endian)
|
||||
header[0x1C] = 0xFE;
|
||||
header[0x1D] = 0xFF;
|
||||
|
||||
// Sector size power
|
||||
header[0x1E] = 0x09;
|
||||
header[0x1F] = 0x00;
|
||||
|
||||
// Mini sector size power
|
||||
header[0x20] = 0x06;
|
||||
header[0x21] = 0x00;
|
||||
|
||||
File.WriteAllBytes(filePath, header);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.OS/StellaOps.Scanner.Analyzers.OS.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Msi/StellaOps.Scanner.Analyzers.OS.Windows.Msi.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="Fixtures\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.OS/StellaOps.Scanner.Analyzers.OS.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="Fixtures\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,272 @@
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.OS.Windows.WinSxS.Tests;
|
||||
|
||||
public class WinSxSManifestParserTests
|
||||
{
|
||||
private readonly WinSxSManifestParser _parser = new();
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithValidManifest_ExtractsMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var manifestPath = CreateTestManifest(@"<?xml version=""1.0"" encoding=""UTF-8""?>
|
||||
<assembly xmlns=""urn:schemas-microsoft-com:asm.v1"" manifestVersion=""1.0"">
|
||||
<assemblyIdentity
|
||||
name=""Microsoft.Windows.Common-Controls""
|
||||
version=""6.0.0.0""
|
||||
processorArchitecture=""x86""
|
||||
publicKeyToken=""6595b64144ccf1df""
|
||||
language=""*""
|
||||
type=""win32"" />
|
||||
<file name=""comctl32.dll"" hash=""abcd1234"" hashalg=""SHA256"" size=""12345"" />
|
||||
</assembly>");
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse(manifestPath, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("Microsoft.Windows.Common-Controls", result.Name);
|
||||
Assert.Equal("6.0.0.0", result.Version);
|
||||
Assert.Equal("x86", result.ProcessorArchitecture);
|
||||
Assert.Equal("6595b64144ccf1df", result.PublicKeyToken);
|
||||
Assert.Equal("*", result.Language);
|
||||
Assert.Equal("win32", result.Type);
|
||||
Assert.Single(result.Files);
|
||||
Assert.Equal("comctl32.dll", result.Files[0].Name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CleanupTestManifest(manifestPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithAmd64Architecture_ExtractsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var manifestPath = CreateTestManifest(@"<?xml version=""1.0"" encoding=""UTF-8""?>
|
||||
<assembly xmlns=""urn:schemas-microsoft-com:asm.v1"" manifestVersion=""1.0"">
|
||||
<assemblyIdentity
|
||||
name=""Microsoft.Windows.SystemCompatible""
|
||||
version=""10.0.19041.1""
|
||||
processorArchitecture=""amd64""
|
||||
publicKeyToken=""31bf3856ad364e35"" />
|
||||
</assembly>");
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse(manifestPath, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("Microsoft.Windows.SystemCompatible", result.Name);
|
||||
Assert.Equal("amd64", result.ProcessorArchitecture);
|
||||
Assert.Equal("31bf3856ad364e35", result.PublicKeyToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CleanupTestManifest(manifestPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithMultipleFiles_ExtractsAllFiles()
|
||||
{
|
||||
// Arrange
|
||||
var manifestPath = CreateTestManifest(@"<?xml version=""1.0"" encoding=""UTF-8""?>
|
||||
<assembly xmlns=""urn:schemas-microsoft-com:asm.v1"" manifestVersion=""1.0"">
|
||||
<assemblyIdentity name=""TestAssembly"" version=""1.0.0.0"" processorArchitecture=""x86"" />
|
||||
<file name=""file1.dll"" />
|
||||
<file name=""file2.dll"" />
|
||||
<file name=""file3.config"" />
|
||||
</assembly>");
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse(manifestPath, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(3, result.Files.Count);
|
||||
Assert.Contains(result.Files, f => f.Name == "file1.dll");
|
||||
Assert.Contains(result.Files, f => f.Name == "file2.dll");
|
||||
Assert.Contains(result.Files, f => f.Name == "file3.config");
|
||||
}
|
||||
finally
|
||||
{
|
||||
CleanupTestManifest(manifestPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithKbReferenceInFilename_ExtractsKbReference()
|
||||
{
|
||||
// Arrange
|
||||
var manifestPath = CreateTestManifestWithName(
|
||||
"amd64_microsoft-windows-security_31bf3856ad364e35_10.0.19041.4170_kb5034441.manifest",
|
||||
@"<?xml version=""1.0"" encoding=""UTF-8""?>
|
||||
<assembly xmlns=""urn:schemas-microsoft-com:asm.v1"" manifestVersion=""1.0"">
|
||||
<assemblyIdentity name=""TestSecurity"" version=""10.0.19041.4170"" processorArchitecture=""amd64"" />
|
||||
</assembly>");
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse(manifestPath, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("KB5034441", result.KbReference);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CleanupTestManifest(manifestPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithNonExistentFile_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse("/nonexistent/path/manifest.manifest", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithInvalidXml_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var manifestPath = CreateTestManifest("not valid xml content");
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse(manifestPath, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CleanupTestManifest(manifestPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithMissingAssemblyIdentity_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var manifestPath = CreateTestManifest(@"<?xml version=""1.0"" encoding=""UTF-8""?>
|
||||
<assembly xmlns=""urn:schemas-microsoft-com:asm.v1"" manifestVersion=""1.0"">
|
||||
<file name=""something.dll"" />
|
||||
</assembly>");
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse(manifestPath, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CleanupTestManifest(manifestPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithEmptyPath_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse(string.Empty, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAssemblyIdentityString_BuildsCorrectFormat()
|
||||
{
|
||||
// Arrange
|
||||
var metadata = new WinSxSAssemblyMetadata(
|
||||
Name: "Microsoft.Windows.Common-Controls",
|
||||
Version: "6.0.0.0",
|
||||
ProcessorArchitecture: "x86",
|
||||
PublicKeyToken: "6595b64144ccf1df",
|
||||
Language: "en-us",
|
||||
Type: "win32",
|
||||
VersionScope: null,
|
||||
ManifestPath: "/test/manifest.manifest",
|
||||
CatalogPath: null,
|
||||
CatalogThumbprint: null,
|
||||
KbReference: null,
|
||||
Files: []);
|
||||
|
||||
// Act
|
||||
var result = WinSxSManifestParser.BuildAssemblyIdentityString(metadata);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("microsoft.windows.common-controls_6.0.0.0_x86_6595b64144ccf1df_en-us", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAssemblyIdentityString_WithNeutralLanguage_OmitsLanguage()
|
||||
{
|
||||
// Arrange
|
||||
var metadata = new WinSxSAssemblyMetadata(
|
||||
Name: "TestAssembly",
|
||||
Version: "1.0.0.0",
|
||||
ProcessorArchitecture: "amd64",
|
||||
PublicKeyToken: null,
|
||||
Language: "*",
|
||||
Type: null,
|
||||
VersionScope: null,
|
||||
ManifestPath: "/test/manifest.manifest",
|
||||
CatalogPath: null,
|
||||
CatalogThumbprint: null,
|
||||
KbReference: null,
|
||||
Files: []);
|
||||
|
||||
// Act
|
||||
var result = WinSxSManifestParser.BuildAssemblyIdentityString(metadata);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("testassembly_1.0.0.0_amd64", result);
|
||||
}
|
||||
|
||||
private static string CreateTestManifest(string content)
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "winsxs-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var manifestPath = Path.Combine(tempDir, "test.manifest");
|
||||
File.WriteAllText(manifestPath, content);
|
||||
return manifestPath;
|
||||
}
|
||||
|
||||
private static string CreateTestManifestWithName(string fileName, string content)
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "winsxs-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var manifestPath = Path.Combine(tempDir, fileName);
|
||||
File.WriteAllText(manifestPath, content);
|
||||
return manifestPath;
|
||||
}
|
||||
|
||||
private static void CleanupTestManifest(string manifestPath)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(manifestPath);
|
||||
if (!string.IsNullOrEmpty(directory) && Directory.Exists(directory))
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.Analyzers.OS.Windows.WinSxS;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.OS.Windows.WinSxS.Tests;
|
||||
|
||||
public class WinSxSPackageAnalyzerTests
|
||||
{
|
||||
private readonly WinSxSPackageAnalyzer _analyzer;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public WinSxSPackageAnalyzerTests()
|
||||
{
|
||||
_logger = NullLoggerFactory.Instance.CreateLogger<WinSxSPackageAnalyzer>();
|
||||
_analyzer = new WinSxSPackageAnalyzer((ILogger<WinSxSPackageAnalyzer>)_logger);
|
||||
}
|
||||
|
||||
private OSPackageAnalyzerContext CreateContext(string rootPath)
|
||||
{
|
||||
return new OSPackageAnalyzerContext(
|
||||
rootPath,
|
||||
workspacePath: null,
|
||||
TimeProvider.System,
|
||||
_logger);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzerId_ReturnsCorrectValue()
|
||||
{
|
||||
Assert.Equal("windows-winsxs", _analyzer.AnalyzerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_WithNoWinSxSDirectory_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "winsxs-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
var context = CreateContext(tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result.Packages);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_WithManifestFiles_ReturnsPackageRecords()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "winsxs-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var manifestsDir = Path.Combine(tempDir, "Windows", "WinSxS", "Manifests");
|
||||
Directory.CreateDirectory(manifestsDir);
|
||||
|
||||
// Create test manifest files
|
||||
CreateTestManifest(manifestsDir, "amd64_test-assembly1_6.0.0.0.manifest",
|
||||
"Test.Assembly1", "6.0.0.0", "amd64");
|
||||
CreateTestManifest(manifestsDir, "x86_test-assembly2_1.2.3.4.manifest",
|
||||
"Test.Assembly2", "1.2.3.4", "x86");
|
||||
|
||||
try
|
||||
{
|
||||
var context = CreateContext(tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Packages.Count);
|
||||
|
||||
var assembly1 = result.Packages.FirstOrDefault(r => r.Name == "Test.Assembly1");
|
||||
Assert.NotNull(assembly1);
|
||||
Assert.Equal("6.0.0.0", assembly1.Version);
|
||||
Assert.Equal("amd64", assembly1.Architecture);
|
||||
Assert.Equal(PackageEvidenceSource.WindowsWinSxS, assembly1.EvidenceSource);
|
||||
Assert.StartsWith("pkg:generic/windows-winsxs/test.assembly1@6.0.0.0", assembly1.PackageUrl);
|
||||
|
||||
var assembly2 = result.Packages.FirstOrDefault(r => r.Name == "Test.Assembly2");
|
||||
Assert.NotNull(assembly2);
|
||||
Assert.Equal("1.2.3.4", assembly2.Version);
|
||||
Assert.Equal("x86", assembly2.Architecture);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_ExtractsVendorMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "winsxs-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var manifestsDir = Path.Combine(tempDir, "Windows", "WinSxS", "Manifests");
|
||||
Directory.CreateDirectory(manifestsDir);
|
||||
|
||||
CreateTestManifest(manifestsDir, "amd64_microsoft-test_6595b64144ccf1df_10.0.0.0_en-us.manifest",
|
||||
"Microsoft.Test", "10.0.0.0", "amd64", "6595b64144ccf1df", "en-us", "win32");
|
||||
|
||||
try
|
||||
{
|
||||
var context = CreateContext(tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result.Packages);
|
||||
var record = result.Packages[0];
|
||||
|
||||
Assert.Equal("Microsoft.Test", record.VendorMetadata["winsxs:name"]);
|
||||
Assert.Equal("10.0.0.0", record.VendorMetadata["winsxs:version"]);
|
||||
Assert.Equal("amd64", record.VendorMetadata["winsxs:arch"]);
|
||||
Assert.Equal("6595b64144ccf1df", record.VendorMetadata["winsxs:public_key_token"]);
|
||||
Assert.Equal("en-us", record.VendorMetadata["winsxs:language"]);
|
||||
Assert.Equal("win32", record.VendorMetadata["winsxs:type"]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_ExtractsPublisherFromAssemblyName()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "winsxs-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var manifestsDir = Path.Combine(tempDir, "Windows", "WinSxS", "Manifests");
|
||||
Directory.CreateDirectory(manifestsDir);
|
||||
|
||||
CreateTestManifest(manifestsDir, "amd64_microsoft-windows-component_1.0.0.0.manifest",
|
||||
"Microsoft.Windows.Component", "1.0.0.0", "amd64");
|
||||
|
||||
try
|
||||
{
|
||||
var context = CreateContext(tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result.Packages);
|
||||
Assert.Equal("Microsoft", result.Packages[0].SourcePackage);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_IncludesFileEvidence()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "winsxs-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var manifestsDir = Path.Combine(tempDir, "Windows", "WinSxS", "Manifests");
|
||||
Directory.CreateDirectory(manifestsDir);
|
||||
|
||||
var manifestPath = Path.Combine(manifestsDir, "amd64_test-assembly_1.0.0.0.manifest");
|
||||
File.WriteAllText(manifestPath, @"<?xml version=""1.0"" encoding=""UTF-8""?>
|
||||
<assembly xmlns=""urn:schemas-microsoft-com:asm.v1"" manifestVersion=""1.0"">
|
||||
<assemblyIdentity name=""Test.Assembly"" version=""1.0.0.0"" processorArchitecture=""amd64"" />
|
||||
<file name=""test.dll"" hash=""abcdef123456"" hashalg=""SHA256"" size=""54321"" />
|
||||
</assembly>");
|
||||
|
||||
try
|
||||
{
|
||||
var context = CreateContext(tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result.Packages);
|
||||
var record = result.Packages[0];
|
||||
|
||||
// Should have manifest file + declared file
|
||||
Assert.Equal(2, record.Files.Count);
|
||||
Assert.Contains(record.Files, f => f.Path.Contains(".manifest"));
|
||||
Assert.Contains(record.Files, f => f.Path.Contains("test.dll"));
|
||||
|
||||
var dllFile = record.Files.First(f => f.Path.Contains("test.dll"));
|
||||
Assert.Equal("sha256:abcdef123456", dllFile.Sha256);
|
||||
Assert.Equal(54321L, dllFile.SizeBytes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_WithInvalidManifest_SkipsAndContinues()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "winsxs-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var manifestsDir = Path.Combine(tempDir, "Windows", "WinSxS", "Manifests");
|
||||
Directory.CreateDirectory(manifestsDir);
|
||||
|
||||
// Create invalid manifest
|
||||
File.WriteAllText(Path.Combine(manifestsDir, "invalid.manifest"), "not valid xml");
|
||||
|
||||
// Create valid manifest
|
||||
CreateTestManifest(manifestsDir, "amd64_valid-assembly_1.0.0.0.manifest",
|
||||
"Valid.Assembly", "1.0.0.0", "amd64");
|
||||
|
||||
try
|
||||
{
|
||||
var context = CreateContext(tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert - Only valid manifest should be returned
|
||||
Assert.Single(result.Packages);
|
||||
Assert.Equal("Valid.Assembly", result.Packages[0].Name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_ResultsAreSortedDeterministically()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "winsxs-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var manifestsDir = Path.Combine(tempDir, "Windows", "WinSxS", "Manifests");
|
||||
Directory.CreateDirectory(manifestsDir);
|
||||
|
||||
// Create manifests in random order
|
||||
CreateTestManifest(manifestsDir, "amd64_zeta-assembly_1.0.0.0.manifest",
|
||||
"Zeta.Assembly", "1.0.0.0", "amd64");
|
||||
CreateTestManifest(manifestsDir, "amd64_alpha-assembly_1.0.0.0.manifest",
|
||||
"Alpha.Assembly", "1.0.0.0", "amd64");
|
||||
CreateTestManifest(manifestsDir, "amd64_mid-assembly_1.0.0.0.manifest",
|
||||
"Mid.Assembly", "1.0.0.0", "amd64");
|
||||
|
||||
try
|
||||
{
|
||||
var context = CreateContext(tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert - Results should be sorted
|
||||
Assert.Equal(3, result.Packages.Count);
|
||||
Assert.Equal("Alpha.Assembly", result.Packages[0].Name);
|
||||
Assert.Equal("Mid.Assembly", result.Packages[1].Name);
|
||||
Assert.Equal("Zeta.Assembly", result.Packages[2].Name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CreateTestManifest(
|
||||
string directory,
|
||||
string fileName,
|
||||
string assemblyName,
|
||||
string version,
|
||||
string architecture,
|
||||
string? publicKeyToken = null,
|
||||
string? language = null,
|
||||
string? type = null)
|
||||
{
|
||||
var manifestPath = Path.Combine(directory, fileName);
|
||||
|
||||
var publicKeyAttr = publicKeyToken is not null ? $@" publicKeyToken=""{publicKeyToken}""" : "";
|
||||
var languageAttr = language is not null ? $@" language=""{language}""" : "";
|
||||
var typeAttr = type is not null ? $@" type=""{type}""" : "";
|
||||
|
||||
var content = $@"<?xml version=""1.0"" encoding=""UTF-8""?>
|
||||
<assembly xmlns=""urn:schemas-microsoft-com:asm.v1"" manifestVersion=""1.0"">
|
||||
<assemblyIdentity
|
||||
name=""{assemblyName}""
|
||||
version=""{version}""
|
||||
processorArchitecture=""{architecture}""{publicKeyAttr}{languageAttr}{typeAttr} />
|
||||
</assembly>";
|
||||
|
||||
File.WriteAllText(manifestPath, content);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user