using System.Text; using System.Buffers.Binary; using System.IO.Compression; using Ludarium.Application; using Ludarium.Domain; using OpenMcdf; namespace Ludarium.UnitTests; public sealed class AnalysisTests { [Theory] [InlineData("3DS/LIMBO/BLUEROMS.WS.html")] [InlineData("3DS/World of Goo/download.htm")] public void WebPagesAreSupportArtifactsRatherThanGames(string path) { var result = Assert.IsType(LibraryContentPolicy.IdentifyNonGamePath(path)); Assert.Equal("NonGameSupport", result.Category); Assert.StartsWith("web-page:", result.Evidence); } [Theory] [InlineData("roms/Tools/Switch/prod.keys")] [InlineData("PC/.gameyfin/cache/cover.jpg")] [InlineData("Game/runtime/libSceAudioOut.prx")] [InlineData("roms/PS2/BIOS/scph39001.bin")] [InlineData("roms/Switch/keys/prod.keys")] [InlineData("PC/Game/manuals/manual.pdf")] [InlineData("roms/N64/Game/readme.txt")] public void NonGameDirectoriesAreDeterministicallyIdentified(string path) { Assert.NotNull(LibraryContentPolicy.IdentifyNonGamePath(path)); Assert.NotNull(LibraryContentPolicy.IdentifyNonGamePath("PC/FMT-v23.23.2/FMT/FMT.exe")); Assert.NotNull(LibraryContentPolicy.IdentifyNonGamePath("PC/Steam/steam.exe")); Assert.NotNull(LibraryContentPolicy.IdentifyNonGamePath("PC/Game DLC Unlocker/tool.exe")); Assert.NotNull(LibraryContentPolicy.IdentifyNonGamePath("PC/Steam/steamapps/common/tool.dll")); Assert.NotNull(LibraryContentPolicy.IdentifyNonGamePath("roms/Tools/Emulators/retroarch.exe")); } [Theory] [InlineData("Super Mario 64 (USA) (Rev 1).z64", "Super Mario 64")] [InlineData("Asterix.and.Obelix.Mission.Babylon-DUPLEX", "Asterix and Obelix Mission Babylon")] [InlineData("Fallout 4 [FitGirl Repack]", "Fallout 4")] [InlineData("Metal Gear Solid (Disc 2)", "Metal Gear Solid")] [InlineData("0002 - Need for Speed - Underground 2 (USA).zip", "Need for Speed - Underground 2")] [InlineData("Animal Crossing- City Folk [RUUE01].wbfs", "Animal Crossing- City Folk")] [InlineData("Pokemon Diamond USA NDS-LGC.nds", "Pokemon Diamond")] [InlineData("Asterix and Obelix Mission Babylon PS5", "Asterix and Obelix Mission Babylon")] [InlineData("Assassins's Creed 4 - Black Flag", "Assassin's Creed 4 - Black Flag")] [InlineData("Call of Duty - Black Ops 6 PS5 iNTERNAL-PS5B", "Call of Duty - Black Ops 6")] [InlineData("Crash Bandicoot - The Wrath of Cortex (USA) (v1", "Crash Bandicoot - The Wrath of Cortex")] [InlineData("Crash Team Racing [U] [SCUS-94426]", "Crash Team Racing")] [InlineData("Burnout Legends", "Burnout Legends")] [InlineData("The Darkness", "The Darkness")] public void CanonicalTitlesRemovePackagingNoise(string raw, string expected) { Assert.Equal(expected, LibraryContentPolicy.CanonicalGameTitle(raw)); } [Fact] public void RomHeaderEvidenceNeverContainsDatabaseInvalidControlCharacters() { var header = new byte[0xC0]; "TEST\0TITLE"u8.CopyTo(header.AsSpan(0xA0)); header[0xB2] = 0x96; var result = ArtifactAnalysis.Classify("synthetic.gba", header); Assert.DoesNotContain(result.Evidence, item => item.Value.Any(char.IsControl)); } [Fact] public async Task HashesAreStreamedAndMatchKnownVectors() { await using var stream = new MemoryStream("abc"u8.ToArray()); var result = await ArtifactAnalysis.HashAsync(stream, true, CancellationToken.None); Assert.Equal("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", result.Sha256); Assert.Equal("a9993e364706816aba3e25717850c26c9cd0d89d", result.Sha1); Assert.Equal("c2412435", result.Crc32); Assert.Equal(3, result.BytesRead); } [Theory] [InlineData("game.nes", new byte[] { 0x4e, 0x45, 0x53, 0x1a }, MediaType.Rom, Confidence.Deterministic)] [InlineData("setup.exe", new byte[] { 0x4d, 0x5a }, MediaType.WindowsPackage, Confidence.Deterministic)] [InlineData("archive.zip", new byte[] { 0x50, 0x4b, 0x03, 0x04 }, MediaType.Archive, Confidence.Deterministic)] [InlineData("mystery.bin", new byte[] { 0x00 }, MediaType.Unknown, Confidence.None)] public void ClassificationUsesSignatureEvidence(string name, byte[] header, MediaType expected, Confidence confidence) { var result = ArtifactAnalysis.Classify(name, header); Assert.Equal(expected, result.MediaType); Assert.Equal(confidence, result.Confidence); Assert.Equal(expected != MediaType.Unknown, result.Supported); } [Fact] public void CueParserHandlesQuotedAndUnquotedReferences() { var refs = ArtifactAnalysis.ParseCueReferences("FILE \"Disc 1.bin\" BINARY\n FILE Disc2.bin BINARY"); Assert.Equal(["Disc 1.bin", "Disc2.bin"], refs); } [Fact] public void M3uParserPreservesOrderAndIgnoresComments() { Assert.Equal(["Disc 2.cue", "Disc 1.cue"], ArtifactAnalysis.ParseM3uReferences("#EXTM3U\nDisc 2.cue\nDisc 1.cue")); } [Theory] [InlineData("../outside.bin", false)] [InlineData("tracks/disc.bin", true)] [InlineData("/absolute.bin", false)] public void DescriptorReferencesCannotEscapeRoot(string path, bool safe) => Assert.Equal(safe, ArtifactAnalysis.IsSafeRelativeReference(path)); [Fact] public void ScanStateMachineRejectsInvalidTransitions() { var run = new ScanRun(Guid.NewGuid(), Guid.NewGuid(), ScanMode.Quick, ScanState.Queued, "Queued", 0, null, 0, null, null, false, DateTimeOffset.UtcNow); Assert.Equal(ScanState.Verifying, run.Transition(ScanState.Verifying, "Verify").State); Assert.Throws(() => run.Transition(ScanState.Completed, "Complete")); } [Fact] public void LibraryRequiresAbsolutePath() => Assert.Throws(() => LibraryRoot.Create("ROMs", "relative", LibraryKind.Rom)); [Fact] public void PeInspectionReadsArchitectureWithoutExecutingImage() { using var stream = File.OpenRead(typeof(AnalysisTests).Assembly.Location); var result = WindowsPackageAnalysis.InspectPe(stream); Assert.True(result.IsManaged); Assert.True(result.Kind is "library" or "executable"); Assert.NotEqual(Confidence.None, result.Confidence); Assert.Contains("ProductVersion", result.VersionInfo.Keys); Assert.Contains(result.Evidence, item => item.Kind == "pe.version.ProductVersion"); } [Fact] public void NumberedInstallerPayloadGapsAreDetected() => Assert.Equal([2], WindowsPackageAnalysis.MissingNumberedPayloads(["setup-1.bin", "setup-3.bin"])); [Fact] public void MsiSummaryInformationIsReadWithoutInvokingWindowsInstaller() { var path = Path.Combine(Path.GetTempPath(), $"ludarium-{Guid.NewGuid():N}.msi"); try { using (var root = RootStorage.Create(path)) using (var summary = root.CreateStream("\u0005SummaryInformation")) summary.Write(CreateSummaryPropertySet((2, "Synthetic Installer"), (4, "Ludarium Tests"), (7, "x64;1033"), (9, "{11111111-2222-3333-4444-555555555555}"))); var result = WindowsPackageAnalysis.InspectMsiSummary(path); Assert.Equal("Synthetic Installer", result["Title"]); Assert.Equal("Ludarium Tests", result["Author"]); Assert.Equal("x64", result["Architecture"]); Assert.Equal("1033", result["Language"]); Assert.Equal("{11111111-2222-3333-4444-555555555555}", result["PackageCode"]); } finally { File.Delete(path); } } [Theory] [InlineData("game.z64", new byte[] { 0x80, 0x37, 0x12, 0x40 }, "n64")] [InlineData("game.v64", new byte[] { 0x37, 0x80, 0x40, 0x12 }, "n64")] [InlineData("game.n64", new byte[] { 0x40, 0x12, 0x37, 0x80 }, "n64")] [InlineData("game.gba", new byte[] { 0, 0, 0, 0, 0x24, 0xff, 0xae, 0x51 }, "gba")] public void RomHeadersOverrideExtensionHints(string name, byte[] header, string platform) { var result = ArtifactAnalysis.Classify(name, header); Assert.Equal(MediaType.Rom, result.MediaType); Assert.Equal(platform, result.Platform); Assert.Equal(Confidence.Deterministic, result.Confidence); } [Theory] [InlineData("game.cso", new byte[] { 0x43, 0x49, 0x53, 0x4f }, "psp", "PSP CSO")] [InlineData("game.nsp", new byte[] { 0x50, 0x46, 0x53, 0x30 }, "switch", "Switch PFS0")] public void ModernContainerSignaturesProvideDeterministicPlatformEvidence(string name, byte[] header, string platform, string signature) { var result = ArtifactAnalysis.Classify(name, header); Assert.Equal(MediaType.DiscImage, result.MediaType); Assert.Equal(platform, result.Platform); Assert.Equal(Confidence.Deterministic, result.Confidence); Assert.Contains(result.Evidence, item => item.Kind == "signature" && item.Value == signature); } [Fact] public void SwitchXciHeaderIsDetectedAtItsSpecifiedOffset() { var header = new byte[0x104]; "HEAD"u8.CopyTo(header.AsSpan(0x100)); var result = ArtifactAnalysis.Classify("game.xci", header); Assert.Equal("switch", result.Platform); Assert.Equal(Confidence.Deterministic, result.Confidence); Assert.Contains(result.Evidence, item => item.Value == "Switch XCI"); var misplaced = ArtifactAnalysis.Classify("roms/PS2/game.xci", header, "ps2"); Assert.Equal("switch", misplaced.Platform); Assert.Contains(misplaced.Evidence, item => item.Kind == "platform.conflict" && item.Value == "directory:ps2;signature:switch"); } [Theory] [InlineData("psx")] [InlineData("ps2")] [InlineData("psp")] public async Task Iso9660ContentsIdentifyPlayStationFamiliesWithoutDirectoryHints(string expectedPlatform) { await using var iso = new MemoryStream(CreatePlayStationIso(expectedPlatform)); var result = await DiscImageAnalysis.InspectIsoAsync(iso, CancellationToken.None); Assert.Equal(expectedPlatform, result.Platform); Assert.Equal(Confidence.Deterministic, result.Confidence); Assert.Contains(result.Evidence, item => item.Kind == "disc.signature" && item.Value == "ISO9660-CD001"); Assert.Contains(result.Evidence, item => item.Kind == "disc.platform" && item.Value.StartsWith(expectedPlatform + ":", StringComparison.Ordinal)); } [Fact] public async Task TruncatedOrGenericIsoNeverInventsAPlatform() { await using var truncated = new MemoryStream(new byte[1024]); Assert.Null((await DiscImageAnalysis.InspectIsoAsync(truncated, CancellationToken.None)).Platform); await using var generic = new MemoryStream(CreatePlayStationIso(null)); var result = await DiscImageAnalysis.InspectIsoAsync(generic, CancellationToken.None); Assert.Null(result.Platform); Assert.Contains(result.Evidence, item => item.Kind == "disc.signature"); } [Theory] [InlineData(false, "psp")] [InlineData(true, "psx")] public async Task PbpPayloadDeterministicallyDistinguishesPspHomebrewFromPsx(bool psx, string expected) { var bytes = new byte[96]; "\0PBP"u8.CopyTo(bytes); BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(4, 4), 0x00010000); var offsets = psx ? new uint[] { 40, 40, 40, 40, 40, 40, 40, 64 } : new uint[] { 40, 40, 40, 40, 40, 40, 40, 96 }; for (var index = 0; index < offsets.Length; index++) BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(8 + index * 4, 4), offsets[index]); if (psx) "PSISOIMG"u8.CopyTo(bytes.AsSpan(64)); else new byte[] { 0x7f, (byte)'E', (byte)'L', (byte)'F' }.CopyTo(bytes.AsSpan(40)); await using var stream = new MemoryStream(bytes); var result = await DiscImageAnalysis.InspectPbpAsync(stream, CancellationToken.None); Assert.Equal(expected, result.Platform); Assert.Equal(Confidence.Deterministic, result.Confidence); Assert.Contains(result.Evidence, item => item.Kind == "disc.signature" && item.Value == "PBP"); } [Fact] public async Task ReproduciblePspHomebrewFixturePassesTheExactPbpInspector() { await using var compressed = File.OpenRead(Path.Combine(AppContext.BaseDirectory, "fixtures", "ludarium-psp-fixture.pbp.gz")); await using var gzip = new GZipStream(compressed, CompressionMode.Decompress); await using var fixture = new MemoryStream(); await gzip.CopyToAsync(fixture); fixture.Position = 0; var result = await DiscImageAnalysis.InspectPbpAsync(fixture, CancellationToken.None); Assert.Equal(130008, fixture.Length); Assert.Equal("psp", result.Platform); Assert.Contains(result.Evidence, item => item.Kind == "disc.platform" && item.Value == "psp:PBP:ELF"); } [Fact] public async Task PbpInspectorRejectsNonMonotonicOffsets() { var bytes = new byte[64]; "\0PBP"u8.CopyTo(bytes); for (var index = 0; index < 8; index++) BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(8 + index * 4, 4), (uint)(40 + index)); BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(24, 4), 39); await using var stream = new MemoryStream(bytes); var result = await DiscImageAnalysis.InspectPbpAsync(stream, CancellationToken.None); Assert.Null(result.Platform); Assert.Equal(Confidence.None, result.Confidence); Assert.Empty(result.Evidence); } [Fact] public void NintendoDsHeaderProvidesSemanticEvidence() { var header = new byte[0x160]; Encoding.ASCII.GetBytes("SYNTHETIC DS").CopyTo(header, 0); Encoding.ASCII.GetBytes("ALPE").CopyTo(header, 12); Encoding.ASCII.GetBytes("01").CopyTo(header, 16); header[18] = 0; header[30] = 2; new byte[] { 0x24, 0xff, 0xae, 0x51 }.CopyTo(header, 0xC0); var result = ArtifactAnalysis.Classify("unknown.bin", header); Assert.Equal("nds", result.Platform); Assert.Equal(Confidence.Deterministic, result.Confidence); Assert.Contains(result.Evidence, item => item.Kind == "rom.title" && item.Value == "SYNTHETIC DS"); Assert.Contains(result.Evidence, item => item.Kind == "rom.gameCode" && item.Value == "ALPE"); Assert.Contains(result.Evidence, item => item.Kind == "rom.region" && item.Value == "usa"); } [Theory] [InlineData(0x7FC0)] [InlineData(0x81C0)] public void SnesHeadersWithAndWithoutCopierPrefixAreDetected(int headerOffset) { var header = new byte[headerOffset + 0x20]; Encoding.ASCII.GetBytes("SYNTHETIC SNES").CopyTo(header, headerOffset); header[headerOffset + 0x15] = 0x20; header[headerOffset + 0x19] = 1; BitConverter.GetBytes((ushort)0xEDCB).CopyTo(header, headerOffset + 0x1C); BitConverter.GetBytes((ushort)0x1234).CopyTo(header, headerOffset + 0x1E); var result = ArtifactAnalysis.Classify("cartridge.dat", header); Assert.Equal(MediaType.Rom, result.MediaType); Assert.Equal("snes", result.Platform); Assert.Contains(result.Evidence, item => item.Kind == "rom.title" && item.Value == "SYNTHETIC SNES"); } [Fact] public void InvalidSnesChecksumDoesNotCreateFalseSignature() { var header = new byte[0x8000]; header[0x7FD5] = 0x20; Assert.Equal(MediaType.Unknown, ArtifactAnalysis.Classify("unknown.dat", header).MediaType); } [Theory] [InlineData("Games", "/library/games", "PC/Example/setup.bin", "windows")] [InlineData("Games", "/library/games", "roms/3ds/Example/game.3ds", "3ds")] [InlineData("Games", "/library/games", "roms/PS1/Example/disc.cue", "psx")] [InlineData("Games", "/library/games", "roms/ngc/Example/game.rvz", "gamecube")] [InlineData("PS4 Games", "/library/ps4", "Example/game.pkg", "ps4")] [InlineData("PS5 Games", "/library/ps5", "Example/game.pkg", "ps5")] public void PlatformHintsFollowConfiguredDirectoryTaxonomy(string libraryName, string libraryPath, string relativePath, string expected) => Assert.Equal(expected, ArtifactAnalysis.InferPlatformHint(libraryName, libraryPath, relativePath)); [Fact] public void DirectoryHintClassifiesPlatformWithoutOverridingDeterministicFormatEvidence() { var hinted = ArtifactAnalysis.Classify("roms/PS2/game.iso", ReadOnlySpan.Empty, "ps2"); Assert.Equal("ps2", hinted.Platform); Assert.Contains(hinted.Evidence, evidence => evidence.Kind == "directory.platform" && evidence.Value == "ps2"); var signatureWins = ArtifactAnalysis.Classify("roms/PS2/misplaced.nes", new byte[] { 0x4e, 0x45, 0x53, 0x1a }, "ps2"); Assert.Equal("nes", signatureWins.Platform); var multipartVolume = ArtifactAnalysis.Classify("PS5-Release/part.v64", ReadOnlySpan.Empty, "ps5"); Assert.Equal("ps5", multipartVolume.Platform); Assert.Equal(MediaType.Unknown, multipartVolume.MediaType); Assert.Contains(multipartVolume.Evidence, evidence => evidence.Kind == "platform.conflict"); } private static byte[] CreateSummaryPropertySet(params (int Id, string Value)[] properties) { var encoded = properties.Select(property => { var value = Encoding.Latin1.GetBytes(property.Value + '\0'); var length = 8 + value.Length; var padded = (length + 3) & ~3; var bytes = new byte[padded]; BitConverter.GetBytes(30).CopyTo(bytes, 0); BitConverter.GetBytes(value.Length).CopyTo(bytes, 4); value.CopyTo(bytes, 8); return (property.Id, Bytes: bytes); }).ToArray(); var sectionOffset = 48; var valuesOffset = 8 + encoded.Length * 8; var sectionSize = valuesOffset + encoded.Sum(item => item.Bytes.Length); var result = new byte[sectionOffset + sectionSize]; BitConverter.GetBytes((ushort)0xfffe).CopyTo(result, 0); BitConverter.GetBytes(1).CopyTo(result, 24); BitConverter.GetBytes(sectionOffset).CopyTo(result, 44); BitConverter.GetBytes(sectionSize).CopyTo(result, sectionOffset); BitConverter.GetBytes(encoded.Length).CopyTo(result, sectionOffset + 4); var cursor = valuesOffset; for (var index = 0; index < encoded.Length; index++) { BitConverter.GetBytes(encoded[index].Id).CopyTo(result, sectionOffset + 8 + index * 8); BitConverter.GetBytes(cursor).CopyTo(result, sectionOffset + 12 + index * 8); encoded[index].Bytes.CopyTo(result, sectionOffset + cursor); cursor += encoded[index].Bytes.Length; } return result; } private static byte[] CreatePlayStationIso(string? platform) { const int sector = 2048; const int rootLba = 20; const int configLba = 22; var image = new byte[24 * sector]; var pvd = image.AsSpan(16 * sector, sector); pvd[0] = 1; "CD001"u8.CopyTo(pvd[1..]); pvd[6] = 1; var directory = image.AsSpan(rootLba * sector, sector); var directoryLength = 0; if (platform is "psp") { directoryLength += WriteIsoRecord(directory[directoryLength..], 21, sector, "PSP_GAME"); directoryLength += WriteIsoRecord(directory[directoryLength..], 23, 32, "UMD_DATA.BIN;1"); } else if (platform is "psx" or "ps2") { var config = platform == "ps2" ? "BOOT2 = cdrom0:\\\\SLUS_000.00;1\r\n" : "BOOT = cdrom:\\\\SCUS_000.00;1\r\n"; var configBytes = Encoding.ASCII.GetBytes(config); configBytes.CopyTo(image, configLba * sector); directoryLength += WriteIsoRecord(directory[directoryLength..], configLba, configBytes.Length, "SYSTEM.CNF;1"); } else { directoryLength += WriteIsoRecord(directory, 23, 16, "README.TXT;1"); } WriteIsoRecord(pvd[156..], rootLba, directoryLength, "\0"); return image; } private static int WriteIsoRecord(Span destination, int extent, int dataLength, string name) { var nameBytes = Encoding.ASCII.GetBytes(name); var length = 33 + nameBytes.Length + (nameBytes.Length % 2 == 0 ? 1 : 0); destination[..length].Clear(); destination[0] = (byte)length; BitConverter.GetBytes(extent).CopyTo(destination[2..]); BitConverter.GetBytes(BinaryPrimitives.ReverseEndianness(extent)).CopyTo(destination[6..]); BitConverter.GetBytes(dataLength).CopyTo(destination[10..]); BitConverter.GetBytes(BinaryPrimitives.ReverseEndianness(dataLength)).CopyTo(destination[14..]); destination[25] = 0; destination[28] = 1; destination[31] = 1; destination[32] = (byte)nameBytes.Length; nameBytes.CopyTo(destination[33..]); return length; } }