177 lines
8.5 KiB
C#
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();
|
|
}
|
|
}
|