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; using SabreTools.Matching; using SabreTools.Numerics.Extensions; #pragma warning disable CA1822 // Mark members as static #pragma warning disable IDE0017 // Simplify object initialization namespace SabreTools.Serialization.Readers { public class ISO9660 : BaseBinaryReader { /// public override Volume? Deserialize(Stream? data) => Deserialize(data, Constants.MinimumSectorSize); /// /// Size of the logical sector used in the volume public Volume? Deserialize(Stream? data, short sectorLength) { // If the data is invalid if (data is null || !data.CanRead) return null; // Ensure the logical sector size is valid (2^n where n>=11) if (sectorLength < Constants.MinimumSectorSize || (sectorLength & (sectorLength - 1)) != 0) return null; // Simple check for a valid stream length if (sectorLength * (Constants.SystemAreaSectors + 2) > data.Length - data.Position) return null; try { // Create a new Volume to fill var volume = new Volume(); // Read the System Area volume.SystemArea = data.ReadBytes(Constants.SystemAreaSectors * sectorLength); // Read the set of Volume Descriptors var vdSet = ParseVolumeDescriptorSet(data, sectorLength); if (vdSet.Length == 0) return null; volume.VolumeDescriptorSet = vdSet; // Parse the path table group(s) for each base volume descriptor var ptgs = ParsePathTableGroups(data, sectorLength, volume.VolumeDescriptorSet); if (ptgs is null || ptgs.Length == 0) return null; volume.PathTableGroups = ptgs; // Parse the root directory descriptor(s) for each base volume descriptor var dirs = ParseDirectoryDescriptors(data, sectorLength, volume.VolumeDescriptorSet); if (dirs is null || dirs.Count == 0) return null; volume.DirectoryDescriptors = dirs; return volume; } catch { // Ignore the actual error return null; } } #region Volume Descriptor Parsing /// /// Parse a Stream into an array of VolumeDescriptor objects /// /// Stream to parse /// Number of bytes in a logical sector (usually 2048) /// Filled VolumeDescriptor[] on success, null on error public static VolumeDescriptor[] ParseVolumeDescriptorSet(Stream data, short sectorLength) { var obj = new List(); bool setTerminated = false; while (data.Position < data.Length) { var volumeDescriptor = ParseVolumeDescriptor(data, sectorLength); // If no valid volume descriptor could be read, return the current set if (volumeDescriptor is null) return [.. obj]; // If the set has already been terminated and the returned volume descriptor is not another terminator, // assume the read volume descriptor is not a valid volume descriptor and return the current set if (setTerminated && volumeDescriptor.Type != VolumeDescriptorType.VOLUME_DESCRIPTOR_SET_TERMINATOR) { // Reset stream to before the just-read volume descriptor data.SeekIfPossible(-sectorLength, SeekOrigin.Current); return [.. obj]; } // Add the valid read volume descriptor to the set obj.Add(volumeDescriptor); // If the set terminator was read, set the set terminated flag (further set terminators may be present) if (!setTerminated && volumeDescriptor.Type == VolumeDescriptorType.VOLUME_DESCRIPTOR_SET_TERMINATOR) setTerminated = true; } return [.. obj]; } /// /// Parse a Stream into a VolumeDescriptor /// /// Stream to parse /// Number of bytes in a logical sector (usually 2048) /// Filled VolumeDescriptor on success, null on error public static VolumeDescriptor? ParseVolumeDescriptor(Stream data, short sectorLength) { var type = (VolumeDescriptorType)data.ReadByteValue(); return type switch { // Known Volume Descriptors defined by ISO9660 VolumeDescriptorType.BOOT_RECORD_VOLUME_DESCRIPTOR => ParseBootRecordVolumeDescriptor(data, sectorLength), VolumeDescriptorType.PRIMARY_VOLUME_DESCRIPTOR => ParsePrimaryVolumeDescriptor(data, sectorLength), VolumeDescriptorType.SUPPLEMENTARY_VOLUME_DESCRIPTOR => ParseSupplementaryVolumeDescriptor(data, sectorLength), VolumeDescriptorType.VOLUME_PARTITION_DESCRIPTOR => ParseVolumePartitionDescriptor(data, sectorLength), VolumeDescriptorType.VOLUME_DESCRIPTOR_SET_TERMINATOR => ParseVolumeDescriptorSetTerminator(data, sectorLength), // Unknown Volume Descriptor _ => ParseGenericVolumeDescriptor(data, sectorLength, type), }; } /// /// Parse a Stream into a BootRecordVolumeDescriptor /// /// Stream to parse /// Number of bytes in a logical sector (usually 2048) /// Filled BootRecordVolumeDescriptor on success, null on error public static BootRecordVolumeDescriptor? ParseBootRecordVolumeDescriptor(Stream data, short sectorLength) { var obj = new BootRecordVolumeDescriptor(); obj.Type = VolumeDescriptorType.VOLUME_PARTITION_DESCRIPTOR; obj.Identifier = data.ReadBytes(5); // Validate Identifier, return null and rewind if invalid if (!obj.Identifier.EqualsExactly(Constants.StandardIdentifier)) { data.SeekIfPossible(-6, SeekOrigin.Current); return null; } obj.Version = data.ReadByteValue(); obj.BootSystemIdentifier = data.ReadBytes(32); obj.BootIdentifier = data.ReadBytes(32); obj.BootSystemUse = data.ReadBytes(1977); // Skip remainder of the logical sector if (sectorLength > Constants.MinimumSectorSize) data.SeekIfPossible(sectorLength - Constants.MinimumSectorSize, SeekOrigin.Current); return obj; } /// /// Parse a Stream into a PrimaryVolumeDescriptor /// /// Stream to parse /// Number of bytes in a logical sector (usually 2048) /// Filled PrimaryVolumeDescriptor on success, null on error public static PrimaryVolumeDescriptor? ParsePrimaryVolumeDescriptor(Stream data, short sectorLength) { var obj = new PrimaryVolumeDescriptor(); obj.Type = VolumeDescriptorType.PRIMARY_VOLUME_DESCRIPTOR; obj.Identifier = data.ReadBytes(5); // Validate Identifier, return null and rewind if invalid if (!obj.Identifier.EqualsExactly(Constants.StandardIdentifier)) { data.SeekIfPossible(-6, SeekOrigin.Current); return null; } obj.Version = data.ReadByteValue(); obj.UnusedByte = data.ReadByteValue(); obj.SystemIdentifier = data.ReadBytes(32); obj.VolumeIdentifier = data.ReadBytes(32); obj.Unused8Bytes = data.ReadBytes(8); obj.VolumeSpaceSize = data.ReadInt32BothEndian(); obj.Unused32Bytes = data.ReadBytes(32); obj.VolumeSetSize = data.ReadInt16BothEndian(); obj.VolumeSequenceNumber = data.ReadInt16BothEndian(); obj.LogicalBlockSize = data.ReadInt16BothEndian(); obj.PathTableSize = data.ReadInt32BothEndian(); obj.PathTableLocationL = data.ReadInt32LittleEndian(); obj.OptionalPathTableLocationL = data.ReadInt32LittleEndian(); obj.PathTableLocationM = data.ReadInt32BigEndian(); obj.OptionalPathTableLocationM = data.ReadInt32BigEndian(); obj.RootDirectoryRecord = ParseDirectoryRecord(data, true); obj.VolumeSetIdentifier = data.ReadBytes(128); obj.PublisherIdentifier = data.ReadBytes(128); obj.DataPreparerIdentifier = data.ReadBytes(128); obj.ApplicationIdentifier = data.ReadBytes(128); obj.CopyrightFileIdentifier = data.ReadBytes(37); obj.AbstractFileIdentifier = data.ReadBytes(37); obj.BibliographicFileIdentifier = data.ReadBytes(37); obj.VolumeCreationDateTime = ParseDecDateTime(data); obj.VolumeModificationDateTime = ParseDecDateTime(data); obj.VolumeExpirationDateTime = ParseDecDateTime(data); obj.VolumeEffectiveDateTime = ParseDecDateTime(data); obj.FileStructureVersion = data.ReadByteValue(); obj.ReservedByte = data.ReadByteValue(); obj.ApplicationUse = data.ReadBytes(512); obj.Reserved653Bytes = data.ReadBytes(653); // Skip remainder of the logical sector if (sectorLength > Constants.MinimumSectorSize) _ = data.ReadBytes(sectorLength - Constants.MinimumSectorSize); return obj; } /// /// Parse a Stream into a SupplementaryVolumeDescriptor /// /// Stream to parse /// Number of bytes in a logical sector (usually 2048) /// Filled SupplementaryVolumeDescriptor on success, null on error public static SupplementaryVolumeDescriptor? ParseSupplementaryVolumeDescriptor(Stream data, short sectorLength) { var obj = new SupplementaryVolumeDescriptor(); obj.Type = VolumeDescriptorType.SUPPLEMENTARY_VOLUME_DESCRIPTOR; obj.Identifier = data.ReadBytes(5); // Validate Identifier, return null and rewind if invalid if (!obj.Identifier.EqualsExactly(Constants.StandardIdentifier)) { data.SeekIfPossible(-6, SeekOrigin.Current); return null; } obj.Version = data.ReadByteValue(); obj.VolumeFlags = (VolumeFlags)data.ReadByteValue(); obj.SystemIdentifier = data.ReadBytes(32); obj.VolumeIdentifier = data.ReadBytes(32); obj.Unused8Bytes = data.ReadBytes(8); obj.VolumeSpaceSize = data.ReadInt32BothEndian(); obj.EscapeSequences = data.ReadBytes(32); obj.VolumeSetSize = data.ReadInt16BothEndian(); obj.VolumeSequenceNumber = data.ReadInt16BothEndian(); obj.LogicalBlockSize = data.ReadInt16BothEndian(); obj.PathTableSize = data.ReadInt32BothEndian(); obj.PathTableLocationL = data.ReadInt32LittleEndian(); obj.OptionalPathTableLocationL = data.ReadInt32LittleEndian(); obj.PathTableLocationM = data.ReadInt32BigEndian(); obj.OptionalPathTableLocationM = data.ReadInt32BigEndian(); obj.RootDirectoryRecord = ParseDirectoryRecord(data, true); obj.VolumeSetIdentifier = data.ReadBytes(128); obj.PublisherIdentifier = data.ReadBytes(128); obj.DataPreparerIdentifier = data.ReadBytes(128); obj.ApplicationIdentifier = data.ReadBytes(128); obj.CopyrightFileIdentifier = data.ReadBytes(37); obj.AbstractFileIdentifier = data.ReadBytes(37); obj.BibliographicFileIdentifier = data.ReadBytes(37); obj.VolumeCreationDateTime = ParseDecDateTime(data); obj.VolumeModificationDateTime = ParseDecDateTime(data); obj.VolumeExpirationDateTime = ParseDecDateTime(data); obj.VolumeEffectiveDateTime = ParseDecDateTime(data); obj.FileStructureVersion = data.ReadByteValue(); obj.ReservedByte = data.ReadByteValue(); obj.ApplicationUse = data.ReadBytes(512); obj.Reserved653Bytes = data.ReadBytes(653); // Skip remainder of the logical sector if (sectorLength > Constants.MinimumSectorSize) _ = data.ReadBytes(sectorLength - Constants.MinimumSectorSize); return obj; } /// /// Parse a Stream into a VolumePartitionDescriptor /// /// Stream to parse /// Number of bytes in a logical sector (usually 2048) /// Filled VolumePartitionDescriptor on success, null on error public static VolumePartitionDescriptor? ParseVolumePartitionDescriptor(Stream data, short sectorLength) { var obj = new VolumePartitionDescriptor(); obj.Type = VolumeDescriptorType.VOLUME_PARTITION_DESCRIPTOR; obj.Identifier = data.ReadBytes(5); // Validate Identifier, return null and rewind if invalid if (!obj.Identifier.EqualsExactly(Constants.StandardIdentifier)) { data.SeekIfPossible(-6, SeekOrigin.Current); return null; } obj.Version = data.ReadByteValue(); obj.UnusedByte = data.ReadByteValue(); obj.SystemIdentifier = data.ReadBytes(32); obj.VolumePartitionIdentifier = data.ReadBytes(32); obj.VolumePartitionLocation = data.ReadInt32BothEndian(); obj.VolumePartitionSize = data.ReadInt32BothEndian(); obj.SystemUse = data.ReadBytes(1960); // Skip remainder of the logical sector if (sectorLength > Constants.MinimumSectorSize) _ = data.ReadBytes(sectorLength - Constants.MinimumSectorSize); return obj; } /// /// Parse a Stream into a VolumeDescriptorSetTerminator /// /// Stream to parse /// Number of bytes in a logical sector (usually 2048) /// Filled VolumeDescriptorSetTerminator on success, null on error public static VolumeDescriptorSetTerminator? ParseVolumeDescriptorSetTerminator(Stream data, short sectorLength) { var obj = new VolumeDescriptorSetTerminator(); obj.Type = VolumeDescriptorType.VOLUME_DESCRIPTOR_SET_TERMINATOR; obj.Identifier = data.ReadBytes(5); // Validate Identifier, return null and rewind if invalid if (!obj.Identifier.EqualsExactly(Constants.StandardIdentifier)) { data.SeekIfPossible(-6, SeekOrigin.Current); return null; } obj.Version = data.ReadByteValue(); obj.Reserved2041Bytes = data.ReadBytes(2041); // Skip remainder of the logical sector if (sectorLength > Constants.MinimumSectorSize) _ = data.ReadBytes(sectorLength - Constants.MinimumSectorSize); return obj; } /// /// Parse a Stream into a GenericVolumeDescriptor /// /// Stream to parse /// Number of bytes in a logical sector (usually 2048) /// Type /// Filled GenericVolumeDescriptor on success, null on error public static GenericVolumeDescriptor? ParseGenericVolumeDescriptor(Stream data, short sectorLength, VolumeDescriptorType type) { var obj = new GenericVolumeDescriptor(); obj.Type = type; obj.Identifier = data.ReadBytes(5); // Validate Identifier, return null and rewind if invalid if (!obj.Identifier.EqualsExactly(Constants.StandardIdentifier)) { data.SeekIfPossible(-6, SeekOrigin.Current); return null; } obj.Version = data.ReadByteValue(); obj.Data = data.ReadBytes(2041); // Skip remainder of the logical sector if (sectorLength > Constants.MinimumSectorSize) _ = data.ReadBytes(sectorLength - Constants.MinimumSectorSize); return obj; } #endregion #region Path Table Parsing /// /// Parse a Stream into an array of PathTableGroup /// /// Stream to parse /// Number of bytes in a logical sector (usually 2048) /// Primary/Supplementary/Enhanced Volume Descriptor pointing to path table(s) /// Filled PathTableGroup[] on success, null on error public static PathTableGroup[]? ParsePathTableGroups(Stream data, short sectorLength, VolumeDescriptor[] vdSet) { var groups = new List(); foreach (VolumeDescriptor vd in vdSet) { // Parse the path table group in the base volume descriptor var pathTableGroups = ParsePathTableGroup(data, sectorLength, vd); if (pathTableGroups.Count > 0) groups.AddRange(pathTableGroups); } // Return error (null) if no valid path table groups were found if (groups.Count == 0) return null; return [.. groups]; } /// /// Parse a Stream into a list of PathTableGroup /// /// Stream to parse /// Number of bytes in a logical sector (usually 2048) /// Primary/Supplementary/Enhanced Volume Descriptor pointing to path table(s) /// Filled list of PathTableGroup on success, null on error public static List ParsePathTableGroup(Stream data, short sectorLength, VolumeDescriptor vd) { int sizeL, sizeB, locationL, locationL2, locationM, locationM2; if (vd is PrimaryVolumeDescriptor pvd) { sizeL = pvd.PathTableSize.LittleEndian; sizeB = pvd.PathTableSize.BigEndian; locationL = pvd.PathTableLocationL; locationL2 = pvd.OptionalPathTableLocationL; locationM = pvd.PathTableLocationM; locationM2 = pvd.OptionalPathTableLocationM; } else if (vd is SupplementaryVolumeDescriptor svd) { sizeL = svd.PathTableSize.LittleEndian; sizeB = svd.PathTableSize.BigEndian; locationL = svd.PathTableLocationL; locationL2 = svd.OptionalPathTableLocationL; locationM = svd.PathTableLocationM; locationM2 = svd.OptionalPathTableLocationM; } else { return []; } var groups = new List(); short blockLength = vd.GetLogicalBlockSize(sectorLength); var groupL = new PathTableGroup(); if (locationL != 0 && ((locationL * blockLength) + sizeL) < data.Length) { data.SeekIfPossible(locationL * blockLength, SeekOrigin.Begin); groupL.PathTableL = ParsePathTable(data, sizeL, true); } if (locationL2 != 0 && ((locationL2 * blockLength) + sizeL) < data.Length) { data.SeekIfPossible(locationL2 * blockLength, SeekOrigin.Begin); groupL.OptionalPathTableL = ParsePathTable(data, sizeL, true); } if (locationM != 0 && ((locationM * blockLength) + sizeL) < data.Length) { data.SeekIfPossible(locationM * blockLength, SeekOrigin.Begin); groupL.PathTableM = ParsePathTable(data, sizeL, false); } if (locationM2 != 0 && ((locationM2 * blockLength) + sizeL) < data.Length) { data.SeekIfPossible(locationM2 * blockLength, SeekOrigin.Begin); groupL.OptionalPathTableM = ParsePathTable(data, sizeL, false); } // If no valid path tables were found, don't add the table group if (groupL.PathTableL is not null || groupL.OptionalPathTableL is not null || groupL.PathTableM is not null || groupL.OptionalPathTableM is not null) groups.Add(groupL); // If the both-endian path table size value is consistent, return the single path table group if (sizeL == sizeB) return groups; // Get the other-sized path table group var groupB = new PathTableGroup(); if (locationL != 0 && ((locationL * blockLength) + sizeB) < data.Length) { data.SeekIfPossible(locationL * blockLength, SeekOrigin.Begin); groupB.PathTableL = ParsePathTable(data, sizeB, true); } if (locationL2 != 0 && ((locationL2 * blockLength) + sizeB) < data.Length) { data.SeekIfPossible(locationL2 * blockLength, SeekOrigin.Begin); groupB.OptionalPathTableL = ParsePathTable(data, sizeB, true); } if (locationM != 0 && ((locationM * blockLength) + sizeB) < data.Length) { data.SeekIfPossible(locationM * blockLength, SeekOrigin.Begin); groupB.PathTableM = ParsePathTable(data, sizeB, false); } if (locationM2 != 0 && ((locationM2 * blockLength) + sizeB) < data.Length) { data.SeekIfPossible(locationM2 * blockLength, SeekOrigin.Begin); groupB.OptionalPathTableM = ParsePathTable(data, sizeB, false); } // If no valid path tables were found, don't add the table group if (groupB.PathTableL is not null || groupB.OptionalPathTableL is not null || groupB.PathTableM is not null || groupB.OptionalPathTableM is not null) groups.Add(groupB); return groups; } /// /// Parse a Stream into an array of path table records /// /// Stream to parse /// Size of the path table /// True if path table is little endian, false if big endian /// Filled array of path table records on success, null on error public static PathTableRecord[]? ParsePathTable(Stream data, int tableSize, bool littleEndian) { var pathTable = new List(); // TODO: Better deal with invalid path table sizes < 10 (manually detect valid records to determine size) // Current status: Trusting path table length field (tableSize) int pos = 0; while (pos < tableSize) { var record = new PathTableRecord(); var directoryIdentifierLength = data.ReadByteValue(); // Check that the current record can fit within the current path table size pos += 8 + directoryIdentifierLength; if (directoryIdentifierLength % 2 != 0) pos += 1; if (pos > tableSize) { // Invalid record length, quit early // TODO: Try detect record length and recover? break; } record.DirectoryIdentifierLength = directoryIdentifierLength; record.ExtendedAttributeRecordLength = data.ReadByteValue(); // Read numerics with correct endianness if (littleEndian) { record.ExtentLocation = data.ReadInt32LittleEndian(); record.ParentDirectoryNumber = data.ReadInt16LittleEndian(); } else { record.ExtentLocation = data.ReadInt32BigEndian(); record.ParentDirectoryNumber = data.ReadInt16BigEndian(); } // Read the directory identifier record.DirectoryIdentifier = data.ReadBytes(record.DirectoryIdentifierLength); // Padding field is present is directory identifier length is odd if (record.DirectoryIdentifierLength % 2 != 0) record.PaddingField = data.ReadByteValue(); pathTable.Add(record); } // Return error (null) if no valid path table records were found if (pathTable.Count == 0) return null; return [.. pathTable]; } #endregion #region Directory Descriptor Parsing /// /// 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 /// Filled Dictionary of int to FileExtent on success, null on error 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; if (vd is PrimaryVolumeDescriptor pvd) rootDirectoryRecord = pvd.RootDirectoryRecord; else if (vd is SupplementaryVolumeDescriptor svd) rootDirectoryRecord = svd.RootDirectoryRecord; else continue; // Determine logical block size short blockLength = vd.GetLogicalBlockSize(sectorLength); // 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; parsed[extent.Offset] = true; ParseExtent(data, sectorLength, extent, directories, pending); } // Return error (null) if no valid directory descriptors were found if (directories.Count == 0) return null; 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 /// /// 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 parsed /// Filled Dictionary of int to FileExtent on success, null on error public static Dictionary? ParseDirectory(Stream data, short sectorLength, short blockLength, DirectoryRecord dr, bool bigEndian) { var directories = new Dictionary(); int blocksPerSector = sectorLength / blockLength; // Use provided extent endinanness int extentLocation = bigEndian ? dr.ExtentLocation.BigEndian : dr.ExtentLocation.LittleEndian; uint extentLength = bigEndian ? dr.ExtentLength.BigEndian : dr.ExtentLength.LittleEndian; long 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 if (extentLength <= 0 || extentFinal > data.Length) return null; // Move stream to directory location data.SeekIfPossible(extentOffset, SeekOrigin.Begin); // Check if the current extent is a directory #if NET20 || NET35 if ((dr.FileFlags & FileFlags.DIRECTORY) == FileFlags.DIRECTORY) #else if (dr.FileFlags.HasFlag(FileFlags.DIRECTORY)) #endif { // Read all directory records in this directory var records = ParseDirectoryRecords(data, sectorLength, extentLength); if (records is null) return null; // Add current directory to dictionary var currentDirectory = new DirectoryExtent(); currentDirectory.DirectoryRecords = [.. records]; directories.Add(extentLocation, currentDirectory); // Add all child directories to dictionary recursively foreach (var record in records) { // Don't traverse to parent or self if (record.FileIdentifier.EqualsExactly(Constants.CurrentDirectory) || record.FileIdentifier.EqualsExactly(Constants.ParentDirectory)) continue; // Recursively parse child directory int sectorNum = record.ExtentLocation * blocksPerSector; var dir = ParseDirectory(data, sectorLength, blockLength, record, false); if (dir is null) continue; // Add new directories to dictionary foreach (var kvp in dir) { if (!directories.ContainsKey(kvp.Key)) directories.Add(kvp.Key, kvp.Value); } } } else { // Extent is a file, parse the Extended Attribute Record var fileExtent = new FileExtent(); if (dr.ExtendedAttributeRecordLength > 0) { var ear = ParseExtendedAttributeRecord(data); if (ear is not null) { fileExtent.ExtendedAttributeRecord = ear; } // Do not parse file data into file extent, too large // Put the file extent is the dictionary #if NETCOREAPP directories.TryAdd(extentLocation, fileExtent); #else if (!directories.ContainsKey(extentLocation)) directories.Add(extentLocation, fileExtent); #endif } } // If the extent location field is ambiguous, also parse the big-endian directory extent if (!bigEndian && !dr.ExtentLocation.IsValid) { var bigEndianDir = ParseDirectory(data, sectorLength, blockLength, dr, true); if (bigEndianDir is not null) { // Add new directories to dictionary foreach (var kvp in bigEndianDir) { if (!directories.ContainsKey(kvp.Key)) directories.Add(kvp.Key, kvp.Value); } } } return directories; } /// /// Parse a Stream into a DirectoryRecord /// /// Stream to parse /// true if root directory record, false otherwise /// Filled DirectoryRecord on success, null on error public static DirectoryRecord ParseDirectoryRecord(Stream data, bool root) { var obj = new DirectoryRecord(); obj.DirectoryRecordLength = data.ReadByteValue(); obj.ExtendedAttributeRecordLength = data.ReadByteValue(); obj.ExtentLocation = data.ReadInt32BothEndian(); obj.ExtentLength = data.ReadUInt32BothEndian(); obj.RecordingDateTime = ParseDirectoryRecordDateTime(data); obj.FileFlags = (FileFlags)data.ReadByteValue(); obj.FileUnitSize = data.ReadByteValue(); obj.InterleaveGapSize = data.ReadByteValue(); obj.VolumeSequenceNumber = data.ReadInt16BothEndian(); obj.FileIdentifierLength = data.ReadByteValue(); // Root directory within the volume descriptor has a single byte file identifier if (root) obj.FileIdentifier = data.ReadBytes(1); else if (obj.FileIdentifierLength > 0) obj.FileIdentifier = data.ReadBytes(obj.FileIdentifierLength); // If file identifier length is even, there is a padding field byte if (obj.FileIdentifierLength % 2 == 0) obj.PaddingField = data.ReadByteValue(); // Root directory within the volume descriptor has no system use bytes, fixed at 34bytes if (root) return obj; // Calculate actual size of record int totalBytes = 33 + obj.FileIdentifierLength; // Calculate the size of the system use section (remaining allocated bytes) int systemUseLength = obj.DirectoryRecordLength - 33 - obj.FileIdentifierLength; // Account for padding field after file identifier if (obj.FileIdentifierLength % 2 == 0) { totalBytes += 1; systemUseLength -= 1; } // If System Use is empty, or if DirectoryRecordLength is bad, return early if (systemUseLength < 1) { // Total record size must be even, read a padding byte if (totalBytes % 2 != 0) obj.SystemUse = data.ReadBytes(1); return obj; } // Total used bytes must be even, read a padding byte totalBytes += systemUseLength; if (totalBytes % 2 != 0) systemUseLength += 1; // Read system use field obj.SystemUse = data.ReadBytes(systemUseLength); return obj; } /// /// Parse a Stream into a ExtendedAttributeRecord /// /// Stream to parse /// true if root directory record, false otherwise /// Filled ExtendedAttributeRecord on success, null on error public static ExtendedAttributeRecord ParseExtendedAttributeRecord(Stream data) { var obj = new ExtendedAttributeRecord(); obj.OwnerIdentification = data.ReadInt16BothEndian(); obj.GroupIdentification = data.ReadInt16BothEndian(); obj.Permissions = (Permissions)data.ReadUInt16LittleEndian(); obj.FileCreationDateTime = ParseDecDateTime(data); obj.FileModificationDateTime = ParseDecDateTime(data); obj.FileExpirationDateTime = ParseDecDateTime(data); obj.FileEffectiveDateTime = ParseDecDateTime(data); obj.RecordFormat = (RecordFormat)data.ReadByteValue(); obj.RecordAttributes = (RecordAttributes)data.ReadByteValue(); obj.RecordLength = data.ReadInt16BothEndian(); obj.SystemIdentifier = data.ReadBytes(32); obj.SystemUse = data.ReadBytes(64); obj.ExtendedAttributeRecordVersion = data.ReadByteValue(); obj.EscapeSequencesLength = data.ReadByteValue(); obj.Reserved64Bytes = data.ReadBytes(64); obj.ApplicationLength = data.ReadInt16BothEndian(); if (obj.ApplicationLength > 0) obj.ApplicationUse = data.ReadBytes(obj.ApplicationLength); if (obj.EscapeSequencesLength > 0) obj.EscapeSequences = data.ReadBytes(obj.EscapeSequencesLength); return obj; } /// /// Parse a Stream into a DirectoryRecordDateTime /// /// Stream to parse /// Filled DirectoryRecordDateTime on success, null on error public static DirectoryRecordDateTime ParseDirectoryRecordDateTime(Stream data) { var obj = new DirectoryRecordDateTime(); obj.YearsSince1990 = data.ReadByteValue(); obj.Month = data.ReadByteValue(); obj.Day = data.ReadByteValue(); obj.Hour = data.ReadByteValue(); obj.Minute = data.ReadByteValue(); obj.Second = data.ReadByteValue(); obj.TimezoneOffset = data.ReadByteValue(); return obj; } #endregion /// /// Parse a Stream into a DecDateTime /// /// Stream to parse /// Filled DecDateTime on success, null on error public static DecDateTime ParseDecDateTime(Stream data) { var obj = new DecDateTime(); obj.Year = data.ReadBytes(4); obj.Month = data.ReadBytes(2); obj.Day = data.ReadBytes(2); obj.Hour = data.ReadBytes(2); obj.Minute = data.ReadBytes(2); obj.Second = data.ReadBytes(2); obj.Centisecond = data.ReadBytes(2); obj.TimezoneOffset = data.ReadByteValue(); 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); } } }