using System.Globalization; using System.IO.Compression; using Ludarium.Application; using Ludarium.Domain; using Ludarium.Infrastructure; namespace Ludarium.IntegrationTests; /// /// CUE/BIN sets served to the browser player as one streamed archive. /// /// /// A CUE sheet is operator content read straight out of a read-only library, so the parser is the /// boundary that decides which files Ludarium will open. An incomplete or escaping set must fail /// closed rather than start a half-readable disc. /// public sealed class DiscSetArchiveTests : IDisposable { private readonly string root = Directory.CreateTempSubdirectory("ludarium-cue-").FullName; public void Dispose() => Directory.Delete(root, recursive: true); [Fact] public void ASingleTrackSheetResolvesToItsOneTrack() { var files = DiscSetArchive.ParseTrackFiles(""" FILE "Ludarium Fixture (Track 1).bin" BINARY TRACK 01 MODE2/2352 INDEX 01 00:00:00 """); Assert.Equal(["Ludarium Fixture (Track 1).bin"], files); } [Fact] public void AMultiTrackSheetKeepsEveryDistinctTrackInOrder() { var files = DiscSetArchive.ParseTrackFiles(""" FILE "Disc (Track 1).bin" BINARY TRACK 01 MODE2/2352 INDEX 01 00:00:00 FILE "Disc (Track 2).bin" BINARY TRACK 02 AUDIO INDEX 01 00:00:00 FILE "Disc (Track 2).bin" BINARY TRACK 03 AUDIO INDEX 01 02:00:00 """); Assert.Equal(["Disc (Track 1).bin", "Disc (Track 2).bin"], files); } [Fact] public void AnUnquotedTrackNameIsStillRead() { Assert.Equal(["game.bin"], DiscSetArchive.ParseTrackFiles("FILE game.bin BINARY\n TRACK 01 MODE1/2352")); } [Theory] [InlineData("FILE \"../../etc/passwd\" BINARY")] [InlineData("FILE \"sub/dir/track.bin\" BINARY")] [InlineData("FILE \"C:\\\\windows\\\\system32\\\\a.bin\" BINARY")] [InlineData("FILE \"payload.exe\" BINARY")] [InlineData("FILE \"..\" BINARY")] public void ASheetThatEscapesItsDirectoryOrNamesAnExecutableIsRejected(string line) => Assert.Throws(() => DiscSetArchive.ParseTrackFiles(line)); [Fact] public void ASheetWithoutTracksIsRejected() { Assert.Throws(() => DiscSetArchive.ParseTrackFiles("REM GENRE Puzzle\nREM DATE 1996")); Assert.Throws(() => DiscSetArchive.ParseTrackFiles("FILENAME whatever.bin BINARY")); } [Fact] public void ASheetNamingMoreFilesThanOneDiscCanHoldIsRejected() { var lines = string.Join('\n', Enumerable.Range(0, DiscSetArchive.MaximumTrackFiles + 2) .Select(index => $"FILE \"track{index:D2}.bin\" BINARY")); Assert.Throws(() => DiscSetArchive.ParseTrackFiles(lines)); } [Fact] public async Task ACompleteSetResolvesWithItsTotalSize() { WriteTrack("roms/psx/Game/Game (Track 1).bin", 4096); WriteTrack("roms/psx/Game/Game (Track 2).bin", 2048); var cue = WriteCue("roms/psx/Game/Game.cue", "Game (Track 1).bin", "Game (Track 2).bin"); var set = await DiscSetArchive.ResolveAsync(FileSystem(), Library(), "roms/psx/Game/Game.cue", cue, 2L * 1024 * 1024 * 1024, CancellationToken.None); Assert.NotNull(set); Assert.Equal(2, set.MemberPaths.Count - 1); Assert.Equal(cue + 4096 + 2048, set.TotalBytes); Assert.All(set.MemberPaths, path => Assert.StartsWith("roms/psx/Game/", path, StringComparison.Ordinal)); } [Fact] public async Task AMissingTrackFailsClosed() { WriteTrack("roms/psx/Game/Game (Track 1).bin", 4096); var cue = WriteCue("roms/psx/Game/Game.cue", "Game (Track 1).bin", "Game (Track 2).bin"); Assert.Null(await DiscSetArchive.ResolveAsync(FileSystem(), Library(), "roms/psx/Game/Game.cue", cue, 2L * 1024 * 1024 * 1024, CancellationToken.None)); } [Fact] public async Task ASetLargerThanThePlatformLimitFailsClosed() { WriteTrack("roms/psx/Game/Game (Track 1).bin", 8192); var cue = WriteCue("roms/psx/Game/Game.cue", "Game (Track 1).bin"); Assert.Null(await DiscSetArchive.ResolveAsync(FileSystem(), Library(), "roms/psx/Game/Game.cue", cue, 4096, CancellationToken.None)); } [Fact] public async Task AnOversizedSheetIsNotEvenRead() { var cue = WriteCue("roms/psx/Game/Game.cue", "Game (Track 1).bin"); Assert.Null(await DiscSetArchive.ResolveAsync(FileSystem(), Library(), "roms/psx/Game/Game.cue", DiscSetArchive.MaximumDescriptorBytes + 1, long.MaxValue, CancellationToken.None)); Assert.Null(await DiscSetArchive.ResolveAsync(FileSystem(), Library(), "roms/psx/Game/Game.iso", cue, long.MaxValue, CancellationToken.None)); } [Fact] public async Task TheStreamedArchiveContainsTheSheetAndEveryTrackVerbatim() { var first = WriteTrack("roms/psx/Game/Game (Track 1).bin", 4096); WriteTrack("roms/psx/Game/Game (Track 2).bin", 2048); var cue = WriteCue("roms/psx/Game/Game.cue", "Game (Track 1).bin", "Game (Track 2).bin"); var files = FileSystem(); var set = await DiscSetArchive.ResolveAsync(files, Library(), "roms/psx/Game/Game.cue", cue, long.MaxValue, CancellationToken.None); var opened = DiscSetArchive.Open(set!, files, Library(), CancellationToken.None); Assert.Equal("Game.zip", opened.FileName); // The reader is a live pipe, so the archive is buffered exactly as the player would receive it. using var buffer = new MemoryStream(); await using (var content = opened.Content) await content.CopyToAsync(buffer); buffer.Position = 0; using var archive = new ZipArchive(buffer, ZipArchiveMode.Read); Assert.Equal(["Game.cue", "Game (Track 1).bin", "Game (Track 2).bin"], archive.Entries.Select(entry => entry.FullName)); Assert.Equal(4096, archive.Entries[1].Length); Assert.Equal(2048, archive.Entries[2].Length); await using var track = archive.Entries[1].Open(); var extracted = new byte[4096]; await track.ReadExactlyAsync(extracted); Assert.Equal(first, extracted); } [Fact] public void APlaylistKeepsEveryDistinctDiscInOrder() { var discs = DiscSetArchive.ParsePlaylist(""" # Ludarium multi-disc fixture Game (Disc 1).cue Game (Disc 2).cue Game (Disc 2).cue """); Assert.Equal(["Game (Disc 1).cue", "Game (Disc 2).cue"], discs); } [Theory] [InlineData("../../etc/passwd")] [InlineData("discs/Game (Disc 1).cue")] [InlineData("Game (Disc 1).m3u")] [InlineData("payload.exe")] public void APlaylistThatEscapesItsDirectoryOrNestsIsRejected(string line) => Assert.Throws(() => DiscSetArchive.ParsePlaylist(line)); [Fact] public void APlaylistWithoutDiscsIsRejected() => Assert.Throws(() => DiscSetArchive.ParsePlaylist("# only a comment\n\n")); [Fact] public void APlaylistNamingMoreDiscsThanOneGameCanHoldIsRejected() { var lines = string.Join('\n', Enumerable.Range(0, DiscSetArchive.MaximumDiscs + 2) .Select(index => $"Disc {index}.chd")); Assert.Throws(() => DiscSetArchive.ParsePlaylist(lines)); } [Fact] public async Task AMultiDiscPlaylistResolvesEveryDiscAndItsTracks() { WriteTrack("roms/psx/Game/Game (Disc 1) (Track 1).bin", 4096); WriteTrack("roms/psx/Game/Game (Disc 2) (Track 1).bin", 2048); WriteCue("roms/psx/Game/Game (Disc 1).cue", "Game (Disc 1) (Track 1).bin"); WriteCue("roms/psx/Game/Game (Disc 2).cue", "Game (Disc 2) (Track 1).bin"); var playlist = WritePlaylist("roms/psx/Game/Game.m3u", "Game (Disc 1).cue", "Game (Disc 2).cue"); var set = await DiscSetArchive.ResolveAsync(FileSystem(), Library(), "roms/psx/Game/Game.m3u", playlist, long.MaxValue, CancellationToken.None); Assert.NotNull(set); Assert.Equal(2, set.DiscCount); // The playlist, both sheets and both tracks travel together or the game does not start. Assert.Equal(5, set.MemberPaths.Count); Assert.Equal("roms/psx/Game/Game.m3u", set.MemberPaths[0]); } [Fact] public async Task APlaylistMissingOneDiscFailsClosed() { WriteTrack("roms/psx/Game/Game (Disc 1) (Track 1).bin", 4096); WriteCue("roms/psx/Game/Game (Disc 1).cue", "Game (Disc 1) (Track 1).bin"); var playlist = WritePlaylist("roms/psx/Game/Game.m3u", "Game (Disc 1).cue", "Game (Disc 2).cue"); Assert.Null(await DiscSetArchive.ResolveAsync(FileSystem(), Library(), "roms/psx/Game/Game.m3u", playlist, long.MaxValue, CancellationToken.None)); } [Fact] public async Task APlaylistMissingOneTrackOfOneDiscFailsClosed() { WriteTrack("roms/psx/Game/Game (Disc 1) (Track 1).bin", 4096); WriteCue("roms/psx/Game/Game (Disc 1).cue", "Game (Disc 1) (Track 1).bin"); WriteCue("roms/psx/Game/Game (Disc 2).cue", "Game (Disc 2) (Track 1).bin"); var playlist = WritePlaylist("roms/psx/Game/Game.m3u", "Game (Disc 1).cue", "Game (Disc 2).cue"); Assert.Null(await DiscSetArchive.ResolveAsync(FileSystem(), Library(), "roms/psx/Game/Game.m3u", playlist, long.MaxValue, CancellationToken.None)); } [Fact] public async Task AMultiDiscArchiveCarriesThePlaylistAndEveryMember() { WriteTrack("roms/psx/Game/Game (Disc 1) (Track 1).bin", 4096); WriteTrack("roms/psx/Game/Game (Disc 2) (Track 1).bin", 2048); WriteCue("roms/psx/Game/Game (Disc 1).cue", "Game (Disc 1) (Track 1).bin"); WriteCue("roms/psx/Game/Game (Disc 2).cue", "Game (Disc 2) (Track 1).bin"); var playlist = WritePlaylist("roms/psx/Game/Game.m3u", "Game (Disc 1).cue", "Game (Disc 2).cue"); var files = FileSystem(); var set = await DiscSetArchive.ResolveAsync(files, Library(), "roms/psx/Game/Game.m3u", playlist, long.MaxValue, CancellationToken.None); var opened = DiscSetArchive.Open(set!, files, Library(), CancellationToken.None); using var buffer = new MemoryStream(); await using (var content = opened.Content) await content.CopyToAsync(buffer); buffer.Position = 0; using var archive = new ZipArchive(buffer, ZipArchiveMode.Read); Assert.Equal("Game.zip", opened.FileName); Assert.Equal(["Game.m3u", "Game (Disc 1).cue", "Game (Disc 1) (Track 1).bin", "Game (Disc 2).cue", "Game (Disc 2) (Track 1).bin"], archive.Entries.Select(entry => entry.FullName)); } [Fact] public void APlaylistIsRecognisedByExtensionAlone() { Assert.True(DiscSetArchive.IsPlaylist("roms/psx/Game/Game.M3U")); Assert.True(DiscSetArchive.IsDescriptor("roms/psx/Game/Game.m3u")); Assert.True(DiscSetArchive.IsDescriptor("roms/psx/Game/Game.cue")); Assert.False(DiscSetArchive.IsDescriptor("roms/psx/Game/Game.chd")); } private long WritePlaylist(string relativePath, params string[] discs) { var path = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar)); Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllText(path, string.Concat(discs.Select(disc => disc + "\n"))); return new FileInfo(path).Length; } [Fact] public void ACueSheetIsRecognisedByExtensionAlone() { Assert.True(DiscSetArchive.IsCueSheet("roms/psx/Game/Game.CUE")); Assert.False(DiscSetArchive.IsCueSheet("roms/psx/Game/Game.bin")); Assert.False(DiscSetArchive.IsCueSheet(null)); } private static ReadOnlyLibraryFileSystem FileSystem() => new(); private LibraryRoot Library() => new(Guid.NewGuid(), "fixture", root, LibraryKind.Rom, true, HashPolicy.Sha256, false, 1, true, true, IsReadOnly: true); private byte[] WriteTrack(string relativePath, int size) { var path = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar)); Directory.CreateDirectory(Path.GetDirectoryName(path)!); var content = Enumerable.Range(0, size).Select(index => (byte)(index % 251)).ToArray(); File.WriteAllBytes(path, content); return content; } private long WriteCue(string relativePath, params string[] tracks) { var path = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar)); Directory.CreateDirectory(Path.GetDirectoryName(path)!); var lines = tracks.Select((track, index) => string.Create(CultureInfo.InvariantCulture, $"FILE \"{track}\" BINARY\n TRACK {index + 1:D2} {(index == 0 ? "MODE2/2352" : "AUDIO")}\n INDEX 01 00:00:00\n")); File.WriteAllText(path, string.Concat(lines)); return new FileInfo(path).Length; } }