Files
Ludarium-Public/tests/Ludarium.UnitTests/MsiDatabaseAnalysisTests.cs
T
Ludarium release export df869819ce
Public source validation / source (push) Successful in 2m16s
Publish Ludarium source
2026-09-03 02:08:58 +02:00

177 lines
8.5 KiB
C#

using System.Buffers.Binary;
using System.Text;
using Ludarium.Application;
using OpenMcdf;
namespace Ludarium.UnitTests;
public sealed class MsiDatabaseAnalysisTests
{
[Fact]
public void ReadsProductMediaAndFileRelationshipsWithoutInstallerApis()
{
var path = CreateFixture(embeddedCabinet: true, includeCabinetStream: true);
try
{
var result = MsiDatabaseAnalysis.Inspect(path);
Assert.Equal("Ludarium Fixture", result.Properties["ProductName"]);
Assert.Equal("2.4.1", result.Properties["ProductVersion"]);
Assert.Equal("1033", result.Properties["ProductLanguage"]);
Assert.Equal("Intel;1033", result.Properties["Template"]);
var cabinet = Assert.Single(result.Cabinets);
Assert.True(cabinet.Embedded);
Assert.True(cabinet.StreamPresent);
Assert.Equal("media1.cab", cabinet.Cabinet);
var file = Assert.Single(result.Files);
Assert.Equal("game.exe", file.FileName);
Assert.Equal(4096, file.Size);
Assert.Equal(1, file.Sequence);
Assert.True(result.IsComplete);
}
finally { File.Delete(path); }
}
[Fact]
public void ReportsMissingEmbeddedCabinetWithoutExtractingIt()
{
var path = CreateFixture(embeddedCabinet: true, includeCabinetStream: false);
try
{
var result = MsiDatabaseAnalysis.Inspect(path);
Assert.False(result.IsComplete);
Assert.Contains(result.Findings, finding => finding.Contains("missing", StringComparison.OrdinalIgnoreCase));
}
finally { File.Delete(path); }
}
[Fact]
public void DistinguishesExternalCabinetRelationships()
{
var path = CreateFixture(embeddedCabinet: false, includeCabinetStream: false);
try
{
var cabinet = Assert.Single(MsiDatabaseAnalysis.Inspect(path).Cabinets);
Assert.False(cabinet.Embedded);
Assert.False(cabinet.StreamPresent);
Assert.Equal("media1.cab", cabinet.Cabinet);
}
finally { File.Delete(path); }
}
[Fact]
public void RejectsTruncatedAndOversizedMetadataStreams()
{
var truncated = Path.Combine(Path.GetTempPath(), $"ludarium-msi-{Guid.NewGuid():N}.msi");
var oversized = Path.Combine(Path.GetTempPath(), $"ludarium-msi-{Guid.NewGuid():N}.msi");
try
{
using (var root = RootStorage.Create(truncated))
using (var stream = root.CreateStream(Encode("_StringPool"))) stream.Write([0xE4, 0x04, 0, 0, 1]);
Assert.ThrowsAny<Exception>(() => MsiDatabaseAnalysis.Inspect(truncated));
using (var root = RootStorage.Create(oversized))
using (var stream = root.CreateStream(Encode("_StringPool"))) stream.Write(new byte[8 * 1024 * 1024 + 1]);
Assert.Throws<InvalidDataException>(() => MsiDatabaseAnalysis.Inspect(oversized));
}
finally { File.Delete(truncated); File.Delete(oversized); }
}
private static string CreateFixture(bool embeddedCabinet, bool includeCabinetStream)
{
var path = Path.Combine(Path.GetTempPath(), $"ludarium-msi-{Guid.NewGuid():N}.msi");
var columns = new Dictionary<string, (string Name, ushort Type)[]>(StringComparer.Ordinal)
{
["Property"] = [("Property", 0x0d48), ("Value", 0x1dff)],
["Media"] = [("DiskId", 0x2502), ("LastSequence", 0x0502), ("DiskPrompt", 0x1dff),
("Cabinet", 0x1dff), ("VolumeLabel", 0x1d20), ("Source", 0x1d48)],
["File"] = [("File", 0x2d48), ("Component_", 0x0d48), ("FileName", 0x0dff),
("FileSize", 0x0104), ("Version", 0x1d48), ("Language", 0x1d14),
("Attributes", 0x0502), ("Sequence", 0x0502)]
};
var propertyRows = new[]
{
new[] { "ProductName", "Ludarium Fixture" }, new[] { "ProductVersion", "2.4.1" },
new[] { "ProductLanguage", "1033" }, new[] { "Manufacturer", "Ludarium" },
new[] { "ProductCode", "{11111111-2222-3333-4444-555555555555}" }, new[] { "Template", "Intel;1033" }
};
var mediaRows = new[] { new[] { "1", "1", "Install media", embeddedCabinet ? "#media1.cab" : "media1.cab", "LUDARIUM", "" } };
var fileRows = new[] { new[] { "GameExe", "GameComponent", "game.exe", "4096", "2.4.1", "1033", "0", "1" } };
var strings = columns.SelectMany(table => new[] { table.Key }.Concat(table.Value.Select(column => column.Name)))
.Concat(propertyRows.SelectMany(row => row)).Concat(mediaRows.SelectMany(row => row))
.Concat(fileRows.SelectMany(row => row)).Where(value => value.Length > 0)
.Distinct(StringComparer.Ordinal).ToList();
var ids = strings.Select((value, index) => (value, id: (ushort)(index + 1))).ToDictionary(item => item.value, item => item.id, StringComparer.Ordinal);
using (var root = RootStorage.Create(path))
{
var data = strings.SelectMany(value => Encoding.Latin1.GetBytes(value)).ToArray();
var pool = new byte[4 + strings.Count * 4];
BinaryPrimitives.WriteUInt16LittleEndian(pool, 1252);
for (var index = 0; index < strings.Count; index++)
{
BinaryPrimitives.WriteUInt16LittleEndian(pool.AsSpan(4 + index * 4, 2), (ushort)Encoding.Latin1.GetByteCount(strings[index]));
BinaryPrimitives.WriteUInt16LittleEndian(pool.AsSpan(6 + index * 4, 2), 1);
}
Write(root, "_StringPool", pool); Write(root, "_StringData", data);
var columnRows = columns.SelectMany(table => table.Value.Select((column, index) => new[]
{ ids[table.Key], (ushort)(index + 1), ids[column.Name], column.Type })).ToArray();
Write(root, "_Columns", WriteU16Columns(columnRows));
Write(root, "Property", WriteTable(columns["Property"], propertyRows, ids));
Write(root, "Media", WriteTable(columns["Media"], mediaRows, ids));
Write(root, "File", WriteTable(columns["File"], fileRows, ids));
if (includeCabinetStream) Write(root, "media1.cab", "MSCF"u8.ToArray());
}
return path;
}
private static byte[] WriteTable((string Name, ushort Type)[] columns, string[][] rows, Dictionary<string, ushort> ids)
{
using var output = new MemoryStream();
foreach (var (column, columnIndex) in columns.Select((value, index) => (value, index)))
foreach (var row in rows)
{
var value = row[columnIndex];
var integer = (column.Type & 0x0f00) < 0x0800;
var width = integer && (column.Type & 0x0fff) == 0x0104 ? 4 : 2;
var raw = integer ? (long.Parse(value, System.Globalization.CultureInfo.InvariantCulture) + (width == 4 ? 0x80000000L : 0x8000L)) : value.Length == 0 ? 0 : ids[value];
Span<byte> bytes = new byte[4];
if (width == 4) BinaryPrimitives.WriteUInt32LittleEndian(bytes, (uint)raw);
else BinaryPrimitives.WriteUInt16LittleEndian(bytes, (ushort)raw);
output.Write(bytes[..width]);
}
return output.ToArray();
}
private static byte[] WriteU16Columns(ushort[][] rows)
{
var bytes = new byte[rows.Length * rows[0].Length * 2];
for (var column = 0; column < rows[0].Length; column++)
for (var row = 0; row < rows.Length; row++)
BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan((column * rows.Length + row) * 2, 2), rows[row][column]);
return bytes;
}
private static void Write(RootStorage root, string name, byte[] bytes)
{
using var stream = root.CreateStream(Encode(name));
stream.Write(bytes);
}
private static string Encode(string name)
{
const string alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz._!";
var output = new StringBuilder();
for (var index = 0; index < name.Length; index += 2)
{
var first = alphabet.IndexOf(name[index]);
var second = index + 1 < name.Length ? alphabet.IndexOf(name[index + 1]) : -1;
if (first >= 0 && second >= 0) output.Append((char)(0x3800 + first + (second << 6)));
else if (first >= 0) output.Append((char)(0x4800 + first));
else { output.Append(name[index]); if (index + 1 < name.Length) { output.Append(name[index + 1]); index++; } }
}
return output.ToString();
}
}