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(() => 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(() => 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(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 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 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(); } }