This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user