diff --git a/SabreTools.Serialization.Readers.Test/ISO9660Tests.cs b/SabreTools.Serialization.Readers.Test/ISO9660Tests.cs index 5367a104..8047930e 100644 --- a/SabreTools.Serialization.Readers.Test/ISO9660Tests.cs +++ b/SabreTools.Serialization.Readers.Test/ISO9660Tests.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text; using SabreTools.Data.Models.ISO9660; using SabreTools.Numerics; using Xunit; @@ -9,6 +10,11 @@ namespace SabreTools.Serialization.Readers.Test { public class ISO9660Tests { + /// + /// File flags of a plain file record + /// + private const FileFlags NoFlags = 0; + [Fact] public void NullArray_Null() { @@ -98,8 +104,8 @@ public void ParseDirectory_ConsistentExtentLocation_ReadsEachDirectoryOnce() { // Sector 1 holds directory A at sector 2, which holds directory B at sector 3 byte[] image = new byte[5 * 2048]; - WriteDirectory(image, 1, "A", 2); - WriteDirectory(image, 2, "B", 3); + WriteDirectory(image, 1, ("A", 2, FileFlags.DIRECTORY)); + WriteDirectory(image, 2, ("B", 3, FileFlags.DIRECTORY)); WriteDirectory(image, 3); var dr = new DirectoryRecord { @@ -118,28 +124,131 @@ public void ParseDirectory_ConsistentExtentLocation_ReadsEachDirectoryOnce() Assert.Equal(1, stream.SeeksTo(3 * 2048)); } + [Fact] + public void ParseDirectoryDescriptors_ReadsExtentsInVolumeOrder() + { + // Root at sector 20 holds A at sector 30 and B at sector 22, A holds C at sector 21 + byte[] image = new byte[32 * 2048]; + WriteDirectory(image, 20, ("A", 30, FileFlags.DIRECTORY), ("B", 22, FileFlags.DIRECTORY)); + WriteDirectory(image, 30, ("C", 21, FileFlags.DIRECTORY)); + WriteDirectory(image, 22); + WriteDirectory(image, 21); + var stream = new SeekCountingStream(image); + + var actual = ISO9660.ParseDirectoryDescriptors(stream, 2048, [CreatePrimaryVolumeDescriptor(20)]); + + Assert.NotNull(actual); + Assert.Equal(new[] { 20, 21, 22, 30 }, actual.Keys.OrderBy(k => k)); + Assert.Equal(new long[] { 20 * 2048, 22 * 2048, 30 * 2048, 21 * 2048 }, stream.SeekOrder); + } + + [Fact] + public void FindDirectoryRecords_NestedPath_ReadsOnlyDirectoriesOnPath() + { + // Root at sector 20 holds OTHER at sector 22 and SUB at sector 30, SUB holds FILE.TXT;1 at sector 25 + byte[] image = new byte[32 * 2048]; + WriteDirectory(image, 20, ("OTHER", 22, FileFlags.DIRECTORY), ("SUB", 30, FileFlags.DIRECTORY)); + WriteDirectory(image, 22); + WriteDirectory(image, 30, ("FILE.TXT;1", 25, NoFlags)); + var stream = new SeekCountingStream(image); + + var actual = ISO9660.FindDirectoryRecords(stream, 2048, CreatePrimaryVolumeDescriptor(20), "sub/file.txt"); + + Assert.NotNull(actual); + Assert.Equal(25, Assert.Single(actual).ExtentLocation.LittleEndian); + Assert.Equal(new long[] { 20 * 2048, 30 * 2048 }, stream.SeekOrder); + } + + [Fact] + public void FindDirectoryRecords_MissingPath_Null() + { + byte[] image = new byte[32 * 2048]; + WriteDirectory(image, 20, ("SUB", 30, FileFlags.DIRECTORY)); + WriteDirectory(image, 30); + + var actual = ISO9660.FindDirectoryRecords(new MemoryStream(image), 2048, CreatePrimaryVolumeDescriptor(20), "SUB/MISSING.TXT"); + + Assert.Null(actual); + } + + [Fact] + public void FindDirectoryRecords_MultiExtentFile_ReturnsAllRecords() + { + // BIG.BIN;1 is recorded in two extents, at sectors 25 and 26 + byte[] image = new byte[32 * 2048]; + WriteDirectory(image, 20, ("BIG.BIN;1", 25, FileFlags.MULTI_EXTENT), ("BIG.BIN;1", 26, NoFlags), ("NEXT.BIN;1", 27, NoFlags)); + + var actual = ISO9660.FindDirectoryRecords(new MemoryStream(image), 2048, CreatePrimaryVolumeDescriptor(20), "BIG.BIN"); + + Assert.NotNull(actual); + Assert.Equal(new[] { 25, 26 }, actual.Select(r => r.ExtentLocation.LittleEndian)); + } + + [Fact] + public void FindDirectoryRecords_SupplementaryVolumeDescriptor_UsesUnicodeNames() + { + byte[] image = new byte[32 * 2048]; + WriteDirectory(image, 20, Encoding.BigEndianUnicode, ("Sub", 30, FileFlags.DIRECTORY)); + WriteDirectory(image, 30, Encoding.BigEndianUnicode, ("File.txt;1", 25, NoFlags)); + var svd = new SupplementaryVolumeDescriptor + { + LogicalBlockSize = 2048, + RootDirectoryRecord = CreatePrimaryVolumeDescriptor(20).RootDirectoryRecord, + }; + + var actual = ISO9660.FindDirectoryRecords(new MemoryStream(image), 2048, svd, "Sub/File.txt"); + + Assert.NotNull(actual); + Assert.Equal(25, Assert.Single(actual).ExtentLocation.LittleEndian); + } + /// - /// Write a one-sector directory extent with self and parent records and an optional child directory + /// Create a primary volume descriptor with 2048-byte blocks and a one-sector root directory /// - private static void WriteDirectory(byte[] image, int sector, string? childName = null, int childSector = 0) + private static PrimaryVolumeDescriptor CreatePrimaryVolumeDescriptor(int rootSector) + { + return new PrimaryVolumeDescriptor + { + LogicalBlockSize = 2048, + RootDirectoryRecord = new DirectoryRecord + { + ExtentLocation = rootSector, + ExtentLength = 2048, + FileFlags = FileFlags.DIRECTORY, + }, + }; + } + + /// + /// Write a one-sector directory extent with self and parent records and the given entries + /// + private static void WriteDirectory(byte[] image, int sector, params (string Name, int Location, FileFlags Flags)[] entries) + => WriteDirectory(image, sector, Encoding.ASCII, entries); + + /// + /// Write a one-sector directory extent with self and parent records and the given entries + /// + private static void WriteDirectory(byte[] image, int sector, Encoding encoding, params (string Name, int Location, FileFlags Flags)[] entries) { int offset = sector * 2048; - offset = WriteRecord(image, offset, Constants.CurrentDirectory, sector); - offset = WriteRecord(image, offset, Constants.ParentDirectory, sector); - if (childName is not null) - WriteRecord(image, offset, System.Text.Encoding.ASCII.GetBytes(childName), childSector); + offset = WriteRecord(image, offset, Constants.CurrentDirectory, sector, FileFlags.DIRECTORY); + offset = WriteRecord(image, offset, Constants.ParentDirectory, sector, FileFlags.DIRECTORY); + foreach (var (name, location, flags) in entries) + { + offset = WriteRecord(image, offset, encoding.GetBytes(name), location, flags); + } } /// - /// Write a directory record for a one-sector directory extent, returns the offset after the record + /// Write a directory record for a one-sector extent, returns the offset after the record /// - private static int WriteRecord(byte[] image, int offset, byte[] identifier, int location) + private static int WriteRecord(byte[] image, int offset, byte[] identifier, int location, FileFlags flags) { int length = 33 + identifier.Length + (identifier.Length % 2 == 0 ? 1 : 0); image[offset] = (byte)length; WriteBothEndian(image, offset + 2, location); WriteBothEndian(image, offset + 10, 2048); - image[offset + 25] = (byte)FileFlags.DIRECTORY; + image[offset + 25] = (byte)flags; image[offset + 28] = 1; image[offset + 31] = 1; image[offset + 32] = (byte)identifier.Length; @@ -160,12 +269,17 @@ private static void WriteBothEndian(byte[] image, int offset, int value) } /// - /// Memory stream that counts absolute seeks per offset + /// Memory stream that records absolute seeks /// private sealed class SeekCountingStream : MemoryStream { private readonly Dictionary _seeks = []; + /// + /// Offsets of all absolute seeks, in order + /// + public List SeekOrder { get; } = []; + public SeekCountingStream(byte[] buffer) : base(buffer) { } public int SeeksTo(long offset) @@ -174,7 +288,10 @@ public int SeeksTo(long offset) public override long Seek(long offset, SeekOrigin loc) { if (loc == SeekOrigin.Begin) + { _seeks[offset] = SeeksTo(offset) + 1; + SeekOrder.Add(offset); + } return base.Seek(offset, loc); } diff --git a/SabreTools.Serialization.Readers/ISO9660.cs b/SabreTools.Serialization.Readers/ISO9660.cs index 7ddd2358..083ff9a3 100644 --- a/SabreTools.Serialization.Readers/ISO9660.cs +++ b/SabreTools.Serialization.Readers/ISO9660.cs @@ -1,5 +1,7 @@ +using System; using System.Collections.Generic; using System.IO; +using System.Text; using SabreTools.Data.Extensions; using SabreTools.Data.Models.ISO9660; using SabreTools.IO.Extensions; @@ -589,6 +591,10 @@ public static List ParsePathTableGroup(Stream data, short sector /// /// Parse a Stream into a map of sector numbers to Directory /// + /// + /// The directory trees of all volume descriptors are read together, in the order of their extents on the volume: + /// always the next known extent at or after the last one read, wrapping around to the lowest one at the end + /// /// Stream to parse /// Number of bytes in a logical sector (usually 2048) /// Set of volume descriptors for a volume @@ -596,6 +602,8 @@ public static List ParsePathTableGroup(Stream data, short sector public static Dictionary? ParseDirectoryDescriptors(Stream data, short sectorLength, VolumeDescriptor[] vdSet) { var directories = new Dictionary(); + var parsed = new Dictionary(); + var pending = new List(); foreach (VolumeDescriptor vd in vdSet) { DirectoryRecord rootDirectoryRecord; @@ -609,17 +617,25 @@ public static List ParsePathTableGroup(Stream data, short sector // Determine logical block size short blockLength = vd.GetLogicalBlockSize(sectorLength); - // Parse the root directory pointed to from the base volume descriptor - var descriptors = ParseDirectory(data, sectorLength, blockLength, rootDirectoryRecord, false); - if (descriptors is null || descriptors.Count == 0) + // Queue the root directory pointed to from the base volume descriptor + QueueExtent(data, blockLength, rootDirectoryRecord, pending); + } + + // Read the queued extents in volume order + long position = 0; + while (pending.Count > 0) + { + int index = FindNextExtent(pending, position); + var extent = pending[index]; + pending.RemoveAt(index); + position = extent.Offset + extent.Length; + + // Each extent is read once + if (parsed.ContainsKey(extent.Offset)) continue; - // Merge dictionaries - foreach (var kvp in descriptors) - { - if (!directories.ContainsKey(kvp.Key)) - directories.Add(kvp.Key, kvp.Value); - } + parsed[extent.Offset] = true; + ParseExtent(data, sectorLength, extent, directories, pending); } // Return error (null) if no valid directory descriptors were found @@ -629,6 +645,333 @@ public static List ParsePathTableGroup(Stream data, short sector return directories; } + /// + /// Queue the extent a directory record points to, if it needs to be read + /// + /// Stream to parse + /// Number of bytes in a logical block (usually 2048) + /// Directory record pointing to the extent + /// Queued extents, sorted by offset + private static void QueueExtent(Stream data, short blockLength, DirectoryRecord dr, List pending) + { + // Only directories and files with an extended attribute record are read + if ((dr.FileFlags & FileFlags.DIRECTORY) != FileFlags.DIRECTORY && dr.ExtendedAttributeRecordLength == 0) + return; + + QueueExtent(data, blockLength, dr, false, pending); + + // If the extent location field is ambiguous, also queue the big-endian extent + if (!dr.ExtentLocation.IsValid) + QueueExtent(data, blockLength, dr, true, pending); + } + + /// + /// Queue the extent a directory record points to, using one endianness + /// + /// Stream to parse + /// Number of bytes in a logical block (usually 2048) + /// Directory record pointing to the extent + /// True if the Big Endian extent location/length should be used + /// Queued extents, sorted by offset + private static void QueueExtent(Stream data, short blockLength, DirectoryRecord dr, bool bigEndian, List pending) + { + if (!TryGetExtent(data, blockLength, dr, bigEndian, out int extentLocation, out long extentOffset, out uint extentLength)) + return; + + var extent = new PendingExtent(extentLocation, extentOffset, extentLength, blockLength, dr); + int index = pending.BinarySearch(extent, PendingExtentComparer.Instance); + pending.Insert(index < 0 ? ~index : index, extent); + } + + /// + /// Find the first queued extent at or after a position, or the first one if there is none + /// + /// Queued extents, sorted by offset + /// Offset just after the last extent read + /// Index of the next extent to read + private static int FindNextExtent(List pending, long position) + { + var probe = new PendingExtent(0, position, 0, 0, null); + int index = pending.BinarySearch(probe, PendingExtentComparer.Instance); + if (index < 0) + { + index = ~index; + } + else + { + while (index > 0 && pending[index - 1].Offset == position) + { + index--; + } + } + + return index < pending.Count ? index : 0; + } + + /// + /// Parse one queued extent and queue the extents its directory records point to + /// + /// Stream to parse + /// Number of bytes in a logical sector (usually 2048) + /// Extent to parse + /// Map of sector numbers to parsed extents + /// Queued extents, sorted by offset + private static void ParseExtent(Stream data, short sectorLength, PendingExtent extent, Dictionary directories, List pending) + { + var dr = extent.Record!; + + // Move stream to extent location + data.SeekIfPossible(extent.Offset, SeekOrigin.Begin); + + // Check if the current extent is a directory + if ((dr.FileFlags & FileFlags.DIRECTORY) == FileFlags.DIRECTORY) + { + // Read all directory records in this directory + var records = ParseDirectoryRecords(data, sectorLength, extent.Length); + if (records is null) + return; + + // Add current directory to dictionary + var currentDirectory = new DirectoryExtent(); + currentDirectory.DirectoryRecords = [.. records]; + if (!directories.ContainsKey(extent.Location)) + directories.Add(extent.Location, currentDirectory); + + // Queue all child extents + foreach (var record in records) + { + // Don't traverse to parent or self + if (record.FileIdentifier.EqualsExactly(Constants.CurrentDirectory) || record.FileIdentifier.EqualsExactly(Constants.ParentDirectory)) + continue; + + QueueExtent(data, extent.BlockLength, record, pending); + } + } + else if (dr.ExtendedAttributeRecordLength > 0) + { + // Extent is a file, parse the Extended Attribute Record + var fileExtent = new FileExtent(); + var ear = ParseExtendedAttributeRecord(data); + if (ear is not null) + fileExtent.ExtendedAttributeRecord = ear; + + // Put the file extent is the dictionary + if (!directories.ContainsKey(extent.Location)) + directories.Add(extent.Location, fileExtent); + } + } + + /// + /// Get the extent a directory record points to, using one endianness + /// + /// Stream to parse + /// Number of bytes in a logical block (usually 2048) + /// Directory record pointing to the extent + /// True if the Big Endian extent location/length should be used + /// Logical block number of the extent + /// Offset of the extent in the stream + /// Number of bytes in the extent + /// True if the extent is valid, false otherwise + private static bool TryGetExtent(Stream data, short blockLength, DirectoryRecord dr, bool bigEndian, out int extentLocation, out long extentOffset, out uint extentLength) + { + // Use provided extent endinanness + extentLocation = bigEndian ? dr.ExtentLocation.BigEndian : dr.ExtentLocation.LittleEndian; + extentLength = bigEndian ? dr.ExtentLength.BigEndian : dr.ExtentLength.LittleEndian; + extentOffset = extentLocation * (long)blockLength; + long extentFinal = extentOffset + extentLength; + + // Deal with extent length ambiguity + if (!dr.ExtentLength.IsValid) + { + // If provided extent length is invalid, use the other value + if (extentLength <= 0 || extentFinal > data.Length) + extentLength = bigEndian ? dr.ExtentLength.LittleEndian : dr.ExtentLength.BigEndian; + } + + // Validate extent length + return extentLength > 0 && extentFinal <= data.Length; + } + + /// + /// Parse the directory records of one directory extent + /// + /// Stream to parse, positioned at the start of the directory extent + /// Number of bytes in a logical sector (usually 2048) + /// Number of bytes in the directory extent + /// Filled list of DirectoryRecord on success, null if the directory is invalid + private static List? ParseDirectoryRecords(Stream data, short sectorLength, uint extentLength) + { + // Start of directory should not be 0 + int firstRecordLength = data.PeekByteValue(); + if (firstRecordLength == 0) + return null; + + // Read all directory records in this directory + var records = new List(); + uint pos = 0; + while (pos < extentLength) + { + // Peek next byte to check whether the next record length is not greater than the end of the dir extent + var recordLength = data.PeekByteValue(); + + // If record length of 0x00, next record begins in next sector + if (recordLength == 0) + { + int paddingLength = (int)((uint)sectorLength - (pos % (uint)sectorLength)); + pos += (uint)paddingLength; + _ = data.ReadBytes(paddingLength); + + // Finish parsing records if end reached + if (pos >= extentLength) + break; + + // Start of sector should not be 0, ignore entire directory + int nextRecordLength = data.PeekByteValue(); + if (nextRecordLength <= 0) + return null; + + continue; + } + + // Ensure record will end in this extent + // TODO: Smartly detect record length for invalid record lengths + pos += recordLength; + if (pos > extentLength) + break; + + // Get the next directory record + var directoryRecord = ParseDirectoryRecord(data, false); + + // Compare recordLength with number of bytes in directoryRecord and ignore the directory if mismatch + var readLength = 33 + directoryRecord.FileIdentifier.Length + (directoryRecord.PaddingField is null ? 0 : 1) + directoryRecord.SystemUse.Length; + if (readLength != recordLength) + return null; + + records.Add(directoryRecord); + } + + return records; + } + + /// + /// Find the directory records of one path, reading only the directories along the path + /// + /// + /// Names are compared case-insensitively and without a version suffix (";1") or a trailing period. + /// A file recorded in several extents returns one directory record per extent, in recorded order. + /// + /// Stream to parse + /// Number of bytes in a logical sector (usually 2048) + /// Primary or supplementary volume descriptor whose directory hierarchy is searched + /// Path from the root directory, separated by '/' or '\' + /// Filled array of DirectoryRecord on success, null if the path was not found + public static DirectoryRecord[]? FindDirectoryRecords(Stream data, short sectorLength, VolumeDescriptor vd, string path) + { + DirectoryRecord current; + if (vd is PrimaryVolumeDescriptor pvd) + current = pvd.RootDirectoryRecord; + else if (vd is SupplementaryVolumeDescriptor svd) + current = svd.RootDirectoryRecord; + else + return null; + + // Determine logical block size + short blockLength = vd.GetLogicalBlockSize(sectorLength); + + // TODO: Better encoding detection (EscapeSequences) + var encoding = vd is SupplementaryVolumeDescriptor ? Encoding.BigEndianUnicode : Encoding.UTF8; + + string[] names = path.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries); + if (names.Length == 0) + return [current]; + + for (int i = 0; i < names.Length; i++) + { + // Only a directory can contain the next name + if ((current.FileFlags & FileFlags.DIRECTORY) != FileFlags.DIRECTORY) + return null; + + // If the extent location field is ambiguous, also search the big-endian directory extent + var found = FindInDirectory(data, sectorLength, blockLength, current, false, encoding, names[i]); + if (found is null && !current.ExtentLocation.IsValid) + found = FindInDirectory(data, sectorLength, blockLength, current, true, encoding, names[i]); + + if (found is null) + return null; + + if (i == names.Length - 1) + return found; + + current = found[0]; + } + + return null; + } + + /// + /// Find the directory records of one name in one directory + /// + /// Stream to parse + /// Number of bytes in a logical sector (usually 2048) + /// Number of bytes in a logical block (usually 2048) + /// Directory record pointing to the directory extent + /// True if the Big Endian extent location/length should be used + /// Encoding of the file identifiers + /// Name to find + /// Filled array of DirectoryRecord on success, null if the name was not found + private static DirectoryRecord[]? FindInDirectory(Stream data, short sectorLength, short blockLength, DirectoryRecord dr, bool bigEndian, Encoding encoding, string name) + { + if (!TryGetExtent(data, blockLength, dr, bigEndian, out _, out long extentOffset, out uint extentLength)) + return null; + + // Move stream to directory location + data.SeekIfPossible(extentOffset, SeekOrigin.Begin); + + var records = ParseDirectoryRecords(data, sectorLength, extentLength); + if (records is null) + return null; + + string wanted = TrimIdentifier(name); + for (int i = 0; i < records.Count; i++) + { + // Don't match parent or self + if (records[i].FileIdentifier.EqualsExactly(Constants.CurrentDirectory) || records[i].FileIdentifier.EqualsExactly(Constants.ParentDirectory)) + continue; + + if (!string.Equals(TrimIdentifier(encoding.GetString(records[i].FileIdentifier)), wanted, StringComparison.OrdinalIgnoreCase)) + continue; + + // A multi-extent file continues in the following records, up to the final one + var matches = new List { records[i] }; + while ((records[i].FileFlags & FileFlags.MULTI_EXTENT) == FileFlags.MULTI_EXTENT && i + 1 < records.Count) + { + i++; + matches.Add(records[i]); + } + + return [.. matches]; + } + + return null; + } + + /// + /// Remove the version suffix and a trailing period from a file identifier + /// + /// File identifier as recorded or requested + /// File identifier without version suffix and trailing period + private static string TrimIdentifier(string identifier) + { + int versionSeparator = identifier.LastIndexOf(';'); + if (versionSeparator >= 0) + identifier = identifier.Substring(0, versionSeparator); + + if (identifier.EndsWith(".")) + identifier = identifier.Substring(0, identifier.Length - 1); + + return identifier; + } + /// /// Parse a Stream into a map of sector numbers to Directory /// @@ -671,54 +1014,10 @@ public static List ParsePathTableGroup(Stream data, short sector if (dr.FileFlags.HasFlag(FileFlags.DIRECTORY)) #endif { - // Start of directory should not be 0 - int firstRecordLength = data.PeekByteValue(); - if (firstRecordLength == 0) - return null; - // Read all directory records in this directory - var records = new List(); - uint pos = 0; - while (pos < extentLength) - { - // Peek next byte to check whether the next record length is not greater than the end of the dir extent - var recordLength = data.PeekByteValue(); - - // If record length of 0x00, next record begins in next sector - if (recordLength == 0) - { - int paddingLength = (int)((uint)sectorLength - (pos % (uint)sectorLength)); - pos += (uint)paddingLength; - _ = data.ReadBytes(paddingLength); - - // Finish parsing records if end reached - if (pos >= extentLength) - break; - - // Start of sector should not be 0, ignore entire directory - int nextRecordLength = data.PeekByteValue(); - if (nextRecordLength <= 0) - return null; - - continue; - } - - // Ensure record will end in this extent - // TODO: Smartly detect record length for invalid record lengths - pos += recordLength; - if (pos > extentLength) - break; - - // Get the next directory record - var directoryRecord = ParseDirectoryRecord(data, false); - - // Compare recordLength with number of bytes in directoryRecord and return null if mismatch - var readLength = 33 + directoryRecord.FileIdentifier.Length + (directoryRecord.PaddingField is null ? 0 : 1) + directoryRecord.SystemUse.Length; - if (readLength != recordLength) - return null; - - records.Add(directoryRecord); - } + var records = ParseDirectoryRecords(data, sectorLength, extentLength); + if (records is null) + return null; // Add current directory to dictionary var currentDirectory = new DirectoryExtent(); @@ -936,5 +1235,60 @@ public static DecDateTime ParseDecDateTime(Stream data) return obj; } + + /// + /// Extent queued for reading + /// + private sealed class PendingExtent + { + /// + /// Logical block number of the extent + /// + public int Location { get; } + + /// + /// Byte offset of the extent + /// + public long Offset { get; } + + /// + /// Length of the extent in bytes + /// + public uint Length { get; } + + /// + /// Number of bytes in a logical block of the volume + /// + public short BlockLength { get; } + + /// + /// Directory record pointing to the extent + /// + public DirectoryRecord? Record { get; } + + public PendingExtent(int location, long offset, uint length, short blockLength, DirectoryRecord? record) + { + Location = location; + Offset = offset; + Length = length; + BlockLength = blockLength; + Record = record; + } + } + + /// + /// Orders queued extents by offset + /// + private sealed class PendingExtentComparer : IComparer + { + /// + /// Shared instance + /// + public static readonly PendingExtentComparer Instance = new(); + + /// + public int Compare(PendingExtent? x, PendingExtent? y) + => (x?.Offset ?? 0).CompareTo(y?.Offset ?? 0); + } } }