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;
}
}