Files
gmipf 1663345195 Add a UDF reader and wrapper (#100)
* Add a UDF reader

Reads the Volume Recognition Sequence, the Anchor Volume Descriptor
Pointer (sector 256, then the end of the volume), the Main or Reserve
Volume Descriptor Sequence, the File Set Descriptor and the directory
hierarchy. Type 1 and Metadata Partitions are mapped, Virtual and
Sparable partitions are not.

File Entries, Allocation Extent Descriptors and directories are read in
ascending sector order with wrap-around, adjacent sectors merged into
one request. FindFileEntry follows one path and reads only the
directories on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add a UDF wrapper

Prints the volume and file set structures and the directory hierarchy,
and extracts the files. CreateDiscImageWrapper tries UDF after ISO 9660
for a stream at offset 0, so discs with both file systems still open as
ISO 9660.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Treat the stream position as the start of the UDF volume

The UDF reader addresses logical sectors from the start of the stream,
so the wrapper hands it a view that starts at the current position and
keeps that offset, like the other wrappers. CreateDiscImageWrapper no
longer skips UDF for a stream that is not at offset 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Address review comments on the UDF code

- Move the descriptor tag check to UDFExtensions, with tests
- Parse Volume Structure Descriptors in their own method
- Use the existing extensions for numbers, the partition type identifier
  and printing entity identifiers
- Use Array.Exists in IsSafeName
- Make Extract non-virtual and remove the printing test
- Use collection expressions and string interpolation

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Parse File Identifier Descriptors in their own method

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add a TODO to use the CRC from Hashing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 17:18:23 +01:00

290 lines
11 KiB
C#

using System.IO;
using System.Linq;
using System.Text;
using SabreTools.Data.Extensions;
using SabreTools.Data.Models.UDF;
using Xunit;
namespace SabreTools.Serialization.Readers.Test
{
public class UDFTests
{
[Fact]
public void NullArray_Null()
{
byte[]? data = null;
int offset = 0;
var deserializer = new UDF();
var actual = deserializer.Deserialize(data, offset);
Assert.Null(actual);
}
[Fact]
public void EmptyArray_Null()
{
byte[]? data = [];
int offset = 0;
var deserializer = new UDF();
var actual = deserializer.Deserialize(data, offset);
Assert.Null(actual);
}
[Fact]
public void InvalidArray_Null()
{
byte[]? data = [.. Enumerable.Repeat<byte>(0xFF, 1024)];
int offset = 0;
var deserializer = new UDF();
var actual = deserializer.Deserialize(data, offset);
Assert.Null(actual);
}
[Fact]
public void NullStream_Null()
{
Stream? data = null;
var deserializer = new UDF();
var actual = deserializer.Deserialize(data);
Assert.Null(actual);
}
[Fact]
public void EmptyStream_Null()
{
Stream? data = new MemoryStream([]);
var deserializer = new UDF();
var actual = deserializer.Deserialize(data);
Assert.Null(actual);
}
[Fact]
public void InvalidStream_Null()
{
Stream? data = new MemoryStream([.. Enumerable.Repeat<byte>(0xFF, 1024)]);
var deserializer = new UDF();
var actual = deserializer.Deserialize(data);
Assert.Null(actual);
}
[Fact]
public void ValidImage_VolumeStructures()
{
var volume = new UDF().Deserialize(new MemoryStream(UDFImageBuilder.Build()));
Assert.NotNull(volume);
Assert.Equal(3, volume.VolumeRecognitionSequence.Length);
Assert.NotNull(volume.AnchorVolumeDescriptorPointer);
Assert.Equal(4, volume.VolumeDescriptorSequence.Length);
var terminator = Assert.IsType<GenericDescriptor>(volume.VolumeDescriptorSequence[3]);
Assert.Equal(TagIdentifier.TERMINATING_DESCRIPTOR, terminator.DescriptorTag.TagIdentifier);
var lvd = volume.GetLogicalVolumeDescriptor();
Assert.NotNull(lvd);
Assert.Equal("TESTVOLUME", lvd.LogicalVolumeIdentifier.DecodeDString());
Assert.IsType<Type1PartitionMap>(Assert.Single(lvd.PartitionMaps));
Assert.NotNull(volume.FileSetDescriptor);
Assert.Equal("TESTSET", volume.FileSetDescriptor.FileSetIdentifier.DecodeDString());
Assert.Equal(2u, volume.FileSetDescriptor.RootDirectoryICB.ExtentLocation.LogicalBlockNumber);
}
[Fact]
public void ValidImage_DirectoryHierarchy()
{
var volume = new UDF().Deserialize(new MemoryStream(UDFImageBuilder.Build()));
Assert.NotNull(volume);
// Root, "README.TXT", "Sub", "data.bin", "Media"; the deleted entry is not followed
Assert.Equal(5, volume.FileEntries.Count);
Assert.Equal(2, volume.Directories.Count);
Assert.Single(volume.AllocationExtentDescriptors);
// The last root entry has no padding and ends exactly at the end of the directory
var root = volume.Directories[UDFExtensions.GetKey(0, 2)];
Assert.Equal(5, root.Length);
Assert.Equal("Media", root[4].FileIdentifier.DecodeDCharacters());
Assert.Empty(root[4].Padding);
var sub = volume.Directories[UDFExtensions.GetKey(0, 5)];
Assert.Equal("data.bin", sub[1].FileIdentifier.DecodeDCharacters());
}
[Fact]
public void FindFileEntry_EmbeddedData()
{
var data = new MemoryStream(UDFImageBuilder.Build());
var volume = new UDF().Deserialize(data);
Assert.NotNull(volume);
var entry = UDF.FindFileEntry(data, volume, "README.TXT", out long key);
Assert.NotNull(entry);
Assert.Equal(UDFExtensions.GetKey(0, 4), key);
Assert.Equal(AllocationDescriptorType.EMBEDDED, entry.ICBTag.GetAllocationDescriptorType());
Assert.Equal("hello world", Encoding.ASCII.GetString(entry.AllocationDescriptors, 0, (int)entry.InformationLength));
}
[Fact]
public void FindFileEntry_CaseInsensitive()
{
var data = new MemoryStream(UDFImageBuilder.Build());
var volume = new UDF().Deserialize(data);
Assert.NotNull(volume);
var entry = UDF.FindFileEntry(data, volume, "/sub\\DATA.BIN", out long key);
Assert.NotNull(entry);
Assert.Equal(UDFExtensions.GetKey(0, 7), key);
}
[Fact]
public void FindFileEntry_DeletedOrMissing_Null()
{
var data = new MemoryStream(UDFImageBuilder.Build());
var volume = new UDF().Deserialize(data);
Assert.NotNull(volume);
Assert.Null(UDF.FindFileEntry(data, volume, "deleted.txt", out long deletedKey));
Assert.Equal(-1, deletedKey);
Assert.Null(UDF.FindFileEntry(data, volume, "Sub/missing.bin", out _));
Assert.Null(UDF.FindFileEntry(data, volume, "README.TXT/data.bin", out _));
}
[Fact]
public void FindFileEntry_WithoutDirectories_ReadsOnlyThePath()
{
var data = new MemoryStream(UDFImageBuilder.Build());
var volume = new UDF().Deserialize(data, includeDirectories: false);
Assert.NotNull(volume);
Assert.Empty(volume.FileEntries);
Assert.Empty(volume.Directories);
var entry = UDF.FindFileEntry(data, volume, "Sub/data.bin", out long key);
Assert.NotNull(entry);
Assert.Equal(UDFExtensions.GetKey(0, 7), key);
// Root, "Sub" and "data.bin" only
Assert.Equal(3, volume.FileEntries.Count);
Assert.Equal(2, volume.Directories.Count);
Assert.False(volume.FileEntries.ContainsKey(UDFExtensions.GetKey(0, 4)));
}
[Fact]
public void AllocationExtent_Followed()
{
byte[] image = UDFImageBuilder.Build();
var data = new MemoryStream(image);
var volume = new UDF().Deserialize(data);
Assert.NotNull(volume);
long key = UDFExtensions.GetKey(0, 7);
var descriptors = volume.GetAllocationDescriptors(key, volume.FileEntries[key]);
Assert.NotNull(descriptors);
Assert.Equal(2, descriptors.Count);
Assert.Equal(8u, descriptors[0].ExtentLocation.LogicalBlockNumber);
Assert.Equal(9u, descriptors[1].ExtentLocation.LogicalBlockNumber);
Assert.Equal((uint)UDFImageBuilder.DataBinLength, UDFExtensions.GetExtentLength(descriptors[0].ExtentLength) + UDFExtensions.GetExtentLength(descriptors[1].ExtentLength));
Assert.True(volume.TryGetSector(descriptors[1].ExtentLocation.GetKey(), out long sector));
Assert.Equal(UDFImageBuilder.PartitionStart + 9, sector);
}
[Fact]
public void NotRecordedExtent_Kept()
{
var volume = new UDF().Deserialize(new MemoryStream(UDFImageBuilder.Build()));
Assert.NotNull(volume);
long key = UDFExtensions.GetKey(0, 11);
var descriptors = volume.GetAllocationDescriptors(key, volume.FileEntries[key]);
Assert.NotNull(descriptors);
var ad = Assert.Single(descriptors);
Assert.Equal(ExtentType.NOT_RECORDED_BUT_ALLOCATED, UDFExtensions.GetExtentType(ad.ExtentLength));
Assert.Equal(100u, UDFExtensions.GetExtentLength(ad.ExtentLength));
}
[Fact]
public void CorruptRootEntry_NoDirectories()
{
var volume = new UDF().Deserialize(new MemoryStream(UDFImageBuilder.Build(corruptRootEntry: true)));
Assert.NotNull(volume);
Assert.NotNull(volume.FileSetDescriptor);
Assert.Empty(volume.FileEntries);
Assert.Empty(volume.Directories);
}
[Fact]
public void CorruptRootEntryCrc_NoDirectories()
{
var volume = new UDF().Deserialize(new MemoryStream(UDFImageBuilder.Build(corruptRootEntryCrc: true)));
Assert.NotNull(volume);
Assert.NotNull(volume.FileSetDescriptor);
Assert.Empty(volume.FileEntries);
Assert.Empty(volume.Directories);
}
[Fact]
public void EmptyPartitionMap_EndsMapTable()
{
var data = new MemoryStream(UDFImageBuilder.Build(emptyPartitionMap: true));
var anchor = UDF.ParseAnchorVolumeDescriptorPointer(data, Constants.AnchorVolumeDescriptorPointerSector);
Assert.NotNull(anchor);
var sequence = UDF.ParseVolumeDescriptorSequence(data, anchor.MainVolumeDescriptorSequenceExtent);
var lvd = Assert.Single(sequence.OfType<LogicalVolumeDescriptor>());
Assert.Empty(lvd.PartitionMaps);
}
[Fact]
public void VolumeDescriptorPointerLoop_Stops()
{
var volume = new UDF().Deserialize(new MemoryStream(UDFImageBuilder.Build(pointerLoop: true)));
Assert.NotNull(volume);
Assert.Equal(5, volume.FileEntries.Count);
}
[Fact]
public void UnreadableSector_OtherEntriesRead()
{
// The "Sub" File Entry (block 5) cannot be read; "README.TXT" (block 4) is read in the same request
byte[] image = UDFImageBuilder.Build();
long bad = UDFImageBuilder.Block(5);
var volume = new UDF().Deserialize(new ThrowingStream(image, bad, bad + UDFImageBuilder.SectorSize));
// Root, "README.TXT" and "Media"
long[] expected = [UDFExtensions.GetKey(0, 2), UDFExtensions.GetKey(0, 4), UDFExtensions.GetKey(0, 11)];
Assert.NotNull(volume);
Assert.Equal(expected, volume.FileEntries.Keys.OrderBy(k => k));
Assert.Single(volume.Directories);
}
/// <summary>
/// Stream that throws an IOException for reads touching a range, like a drive with an unreadable sector
/// </summary>
private sealed class ThrowingStream(byte[] data, long badStart, long badEnd) : MemoryStream(data)
{
public override int Read(byte[] buffer, int offset, int count)
{
if (Position < badEnd && Position + count > badStart)
throw new IOException("Unreadable sector");
return base.Read(buffer, offset, count);
}
}
}
}