diff --git a/Compression.Tests/FileSystems/FileSizeBoundaryTests.cs b/Compression.Tests/FileSystems/FileSizeBoundaryTests.cs index 6c13e928c..6de3e0978 100644 --- a/Compression.Tests/FileSystems/FileSizeBoundaryTests.cs +++ b/Compression.Tests/FileSystems/FileSizeBoundaryTests.cs @@ -47,7 +47,7 @@ public sealed class FileSizeBoundaryTests { /// private static readonly string[] Filesystems = [ "Fat", "ExFat", "Jfs", "HfsPlus", "Btrfs", "Ext", - "CramFs", "Erofs", "MinixFs", "SquashFs", "Iso", "ReiserFs", "Jffs2", + "CramFs", "Erofs", "MinixFs", "SquashFs", "Iso", "ReiserFs", "Jffs2", "Udf", ]; /// diff --git a/Compression.Tests/FilesystemDrivers/UdfFilesystemDriverTests.cs b/Compression.Tests/FilesystemDrivers/UdfFilesystemDriverTests.cs index 57dd6d4e1..48596f599 100644 --- a/Compression.Tests/FilesystemDrivers/UdfFilesystemDriverTests.cs +++ b/Compression.Tests/FilesystemDrivers/UdfFilesystemDriverTests.cs @@ -124,8 +124,14 @@ public void ExistingExtractorAlsoFollowsMultipleAllocationDescriptors() { Assert.That(reader.Extract(entry), Is.EqualTo(payload)); } + /// + /// A continuation descriptor points at a block that must open with an + /// Allocation Extent Descriptor (ECMA-167 §4/14.5). Aiming one at the file's + /// own data instead leaves the object's extents unreachable, and the mount + /// has to close rather than serve a file it cannot address. + /// [Test, Category("Driver"), Category("Corruption")] - public void ContinuationAllocationDescriptorFailsMountClosed() { + public void BrokenContinuationAllocationDescriptorFailsMountClosed() { var payload = Enumerable.Repeat((byte)0xA5, 6000).ToArray(); var bytes = BuildImage(("continued.bin", payload)); var fe = FindRegularFileEntry(bytes, payload.Length); @@ -139,7 +145,8 @@ public void ContinuationAllocationDescriptorFailsMountClosed() { Assert.Multiple(() => { Assert.That(profile.CanMount, Is.False); - Assert.That(profile.Limitations.Any(text => text.Contains("continuation", StringComparison.OrdinalIgnoreCase)), Is.True); + Assert.That(profile.Limitations.Any(text => text.Contains("logical file bytes", StringComparison.OrdinalIgnoreCase)), Is.True, + string.Join("; ", profile.Limitations)); }); } diff --git a/Compression.Tests/Udf/UdfModifierTests.cs b/Compression.Tests/Udf/UdfModifierTests.cs index f75d90fe3..c7ceb7cb9 100644 --- a/Compression.Tests/Udf/UdfModifierTests.cs +++ b/Compression.Tests/Udf/UdfModifierTests.cs @@ -210,10 +210,11 @@ public void AfterAdd_PartitionDescriptorTagStillValid() { UdfModifier.AddFile(img, "extra.bin", new byte[200]); var bytes = img.ToArray(); - // PD is at sector 33 in writer layout. - var off = 33 * SectorSize; + // The main volume descriptor sequence starts at sector 32 with the primary + // volume descriptor, then the logical volume descriptor, then the partition. + var off = 34 * SectorSize; Assert.That(BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(off)), Is.EqualTo((ushort)5), - "expected Partition Descriptor at sector 33"); + "expected Partition Descriptor at sector 34"); var crcLen = BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(off + 10)); var storedCrc = BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(off + 8)); diff --git a/Compression.Tests/Udf/UdfNativeToolTests.cs b/Compression.Tests/Udf/UdfNativeToolTests.cs new file mode 100644 index 000000000..02a40827d --- /dev/null +++ b/Compression.Tests/Udf/UdfNativeToolTests.cs @@ -0,0 +1,294 @@ +#pragma warning disable CS1591 +using System.Diagnostics; +using System.Text; +using Compression.Tests.Support; +using FileSystem.Udf; + +namespace Compression.Tests.Udf; + +/// +/// Checks both directions of the UDF implementation against the software that +/// owns the format on this host: udfinfo and the kernel's udf +/// driver read what we write, and what mkudffs plus that same driver +/// wrote is read back by us. +/// +/// +/// +/// A writer and a reader that only ever meet each other agree on their own +/// mistakes. Every check here therefore has foreign software on one side of it: +/// nothing in this fixture passes because our reader liked our writer. +/// +/// +/// Each test says which tool it needs and stands down when the host does not +/// have it. A tool that is present and rejects the volume is a failure, never +/// a skip. +/// +/// +[TestFixture] +[Category("ExternalFsInterop")] +public sealed class UdfNativeToolTests { + + private const int BlockSize = 2048; + + private string _temp = null!; + + [SetUp] + public void Setup() { + this._temp = Path.Combine(Path.GetTempPath(), "cwb_udf_" + Guid.NewGuid().ToString("N")[..12]); + Directory.CreateDirectory(this._temp); + } + + [TearDown] + public void Teardown() { + try { Directory.Delete(this._temp, recursive: true); } catch { /* best effort */ } + } + + // ── the volume we write ─────────────────────────────────────────────────── + + /// + /// Content for a file of bytes, every byte a + /// function of its own offset so a misplaced block shows up as wrong bytes + /// rather than merely a wrong length. + /// + private static byte[] Pattern(int size) { + var body = new byte[size]; + for (var i = 0; i < size; ++i) + body[i] = (byte)((i * 2_654_435_761L) >> 13); + return body; + } + + /// + /// The shapes that break UDF writers: the sizes either side of a logical + /// block, an empty file, names that need both OSTA compressions, a name at + /// the identifier's length limit, nesting, and a directory with enough + /// entries that its File Identifier Descriptors run past one block — which + /// is where a record has to be allowed to span the boundary. + /// + private static IReadOnlyList<(string Name, byte[] Data)> Corpus() { + var files = new List<(string, byte[])>(); + foreach (var size in new[] { 0, 1, 2047, 2048, 2049, 4095, 4096, 4097, 65_537 }) + files.Add(($"sizes/size_{size:D8}.bin", Pattern(size))); + + files.Add(("names/" + new string('n', 180) + ".txt", "long identifier\n"u8.ToArray())); + foreach (var name in new[] { "café.bin", "äöü.txt", "日本語.txt", "русский.dat" }) + files.Add(("names/" + name, Encoding.UTF8.GetBytes(name + "\n"))); + + var nested = ""; + for (var depth = 0; depth < 5; ++depth) { + nested += $"deep{depth}/"; + files.Add((nested + "leaf.bin", Encoding.ASCII.GetBytes($"depth {depth}\n"))); + } + + for (var i = 0; i < 200; ++i) + files.Add(($"many/entry_{i:D4}.txt", Encoding.ASCII.GetBytes($"entry number {i}\n"))); + + return files; + } + + private string WriteOurVolume(string fileName = "ours.img") { + var writer = new UdfWriter { VolumeIdentifier = "CWBUDF" }; + foreach (var (name, data) in Corpus()) + writer.AddFile(name, data); + + var path = Path.Combine(this._temp, fileName); + using (var output = File.Create(path)) + writer.WriteTo(output); + return path; + } + + // ── write direction ─────────────────────────────────────────────────────── + + /// + /// udfinfo is the format's own tool, and it reports every structure a + /// UDF volume is supposed to carry that is missing. It exits zero either way, + /// so the volume is only accepted when it also had nothing to say: before the + /// reserve descriptor sequence, the second anchor, the unallocated space and + /// implementation use descriptors and the integrity descriptor were written, + /// it printed seven warnings and called the logical volume inconsistent. + /// + [Test] + public void UdfinfoAcceptsOurVolumeWithoutWarnings() { + var udfinfo = Which("udfinfo"); + if (udfinfo == null) + Assert.Ignore("udfinfo is not installed (udftools)."); + + var image = this.WriteOurVolume(); + var (stdout, stderr, exit) = Run(udfinfo!, Quote(image)); + + Assert.Multiple(() => { + Assert.That(exit, Is.Zero, $"udfinfo exit {exit}\n{stdout}\n{stderr}"); + Assert.That(stderr.Trim(), Is.Empty, $"udfinfo complained about our volume:\n{stderr}"); + }); + + var fields = ParseFields(stdout); + Assert.Multiple(() => { + Assert.That(fields.GetValueOrDefault("label"), Is.EqualTo("CWBUDF"), + "the volume identifier is a dstring: a compression byte, the characters, and the used length in the field's last byte"); + Assert.That(fields.GetValueOrDefault("lvid"), Is.EqualTo("CWBUDF")); + Assert.That(fields.GetValueOrDefault("fsid"), Is.EqualTo("CWBUDF")); + Assert.That(fields.GetValueOrDefault("integrity"), Is.EqualTo("closed"), + "without a logical volume integrity descriptor the volume reads as inconsistent"); + Assert.That(fields.GetValueOrDefault("udfrev"), Is.EqualTo("2.01")); + Assert.That(fields.GetValueOrDefault("blocksize"), Is.EqualTo("2048")); + Assert.That(fields.GetValueOrDefault("numfiles"), Is.EqualTo(Corpus().Count.ToString())); + }); + } + + /// + /// The kernel's udf driver reads a directory as one uninterrupted run of File + /// Identifier Descriptors and lets a record span a block boundary. Padding + /// the boundary instead made it stop at the first pad byte — "entry at pos + /// 2020 with incorrect tag 0" — and lose every entry past it, which is most + /// of a directory of any size. + /// + [Test] + public void TheKernelDriverReadsEveryFileBackFromOurVolume() { + var image = this.WriteOurVolume(); + var expected = Corpus().Select(static entry => entry.Data).ToList(); + + var result = ThirdPartyFsCheck.ReadBack("Udf", image, expected); + if (!result.Ran) + Assert.Ignore(result.Detail); + + Assert.That(result.Ok, Is.True, $"{result.Tool}: {result.Detail}"); + } + + // ── read direction ──────────────────────────────────────────────────────── + + /// + /// A volume neither our writer nor our reader had a hand in: mkudffs + /// formats it, the kernel fills it, and only then does our reader see it. + /// + /// + /// Run for each block size mkudffs will format, because the anchor sits at + /// logical block 256 and that address is counted in blocks: assuming 2048 + /// looked for it in the wrong place on every other volume and the reader + /// threw before it read anything at all. At 512 bytes a block, a directory of + /// two hundred entries also outgrows the descriptors its File Entry has room + /// for, so the rest of them live in a continuation extent the reader has to + /// follow. + /// + [TestCase(512)] + [TestCase(1024)] + [TestCase(2048)] + [TestCase(4096)] + public void OurReaderReadsAVolumeTheNativeToolsBuilt(int blockSize) { + var mkudffs = Which("mkudffs"); + if (mkudffs == null) + Assert.Ignore("mkudffs is not installed (udftools)."); + if (!CanMount) + Assert.Ignore("filling a volume needs the kernel's udf driver and passwordless sudo."); + + var image = Path.Combine(this._temp, $"native_{blockSize}.img"); + using (var file = File.Create(image)) + file.SetLength(32L * 1024 * 1024); + + var (mkStdout, mkStderr, mkExit) = Run(mkudffs!, + $"--media-type=hd --blocksize={blockSize} --udfrev=2.01 --label=NATIVE {Quote(image)}"); + Assert.That(mkExit, Is.Zero, $"mkudffs exit {mkExit}\n{mkStdout}\n{mkStderr}"); + + var written = FillThroughTheKernel(image); + if (written == null) + Assert.Ignore("the kernel refused to mount a volume mkudffs had just made writable."); + + using var stream = File.OpenRead(image); + using var reader = new UdfReader(stream, leaveOpen: true); + + var got = new Dictionary(StringComparer.Ordinal); + foreach (var entry in reader.Entries) { + if (entry.IsDirectory) continue; + using var buffer = new MemoryStream(); + reader.ExtractTo(entry, buffer); + got[entry.Name] = buffer.ToArray(); + } + + Assert.Multiple(() => { + foreach (var (name, data) in written!) { + Assert.That(got.ContainsKey(name), Is.True, $"our reader lost {name}"); + if (got.TryGetValue(name, out var actual)) + Assert.That(actual, Is.EqualTo(data), $"{name} came back with different bytes"); + } + + Assert.That(got, Has.Count.EqualTo(written.Count), + "our reader invented entries the kernel did not put there"); + }); + } + + /// + /// Mounts writable, writes the corpus into it with + /// the kernel's own driver, and returns what was written keyed by the path + /// our reader will report. Null when the volume would not mount. + /// + private static IReadOnlyDictionary? FillThroughTheKernel(string image) { + var mountPoint = Path.Combine(Path.GetTempPath(), "cwb_udfrw_" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(mountPoint); + try { + var uid = Run("id", "-u").StdOut.Trim(); + var gid = Run("id", "-g").StdOut.Trim(); + var (_, stderr, exit) = Run("sudo", + $"-n mount -t udf -o loop,noatime,uid={uid},gid={gid} {Quote(image)} {Quote(mountPoint)}"); + if (exit != 0) { + TestContext.Out.WriteLine($"mount refused the mkudffs volume: {stderr}"); + return null; + } + + try { + var written = new Dictionary(StringComparer.Ordinal); + foreach (var (name, data) in Corpus()) { + var target = Path.Combine(mountPoint, name); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.WriteAllBytes(target, data); + written[name] = data; + } + + return written; + } finally { + Run("sudo", $"-n umount {Quote(mountPoint)}"); + } + } finally { + try { Directory.Delete(mountPoint, recursive: true); } catch { /* the mount point may be gone */ } + } + } + + // ── process plumbing ────────────────────────────────────────────────────── + + private static readonly bool CanMount = OperatingSystem.IsLinux() && Run("sudo", "-n true").Exit == 0; + + private static Dictionary ParseFields(string stdout) { + var fields = new Dictionary(StringComparer.Ordinal); + foreach (var line in stdout.Split('\n')) { + var split = line.IndexOf('='); + if (split > 0) + fields[line[..split].Trim()] = line[(split + 1)..].Trim(); + } + + return fields; + } + + private static string Quote(string path) => "\"" + path + "\""; + + private static string? Which(string tool) { + var (stdout, _, exit) = Run("which", tool); + var path = stdout.Trim(); + return exit == 0 && path.Length > 0 ? path : null; + } + + private static (string StdOut, string StdErr, int Exit) Run(string file, string arguments) { + try { + var start = new ProcessStartInfo(file, arguments) { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + using var process = Process.Start(start); + if (process == null) return ("", "could not start " + file, -1); + var stdout = process.StandardOutput.ReadToEnd(); + var stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(120_000); + return (stdout, stderr, process.ExitCode); + } catch (Exception ex) { + return ("", ex.Message, -1); + } + } +} diff --git a/Compression.Tests/Udf/UdfOnDiskStructureTests.cs b/Compression.Tests/Udf/UdfOnDiskStructureTests.cs new file mode 100644 index 000000000..bf3591dfd --- /dev/null +++ b/Compression.Tests/Udf/UdfOnDiskStructureTests.cs @@ -0,0 +1,253 @@ +#pragma warning disable CS1591 +using System.Buffers.Binary; +using System.Text; +using FileSystem.Udf; + +namespace Compression.Tests.Udf; + +/// +/// Reads the bytes our writer produces and checks the few structural rules that +/// other implementations depend on. These need no tool installed, so they hold +/// the line on a host where the native checks stand down. +/// +[TestFixture] +public sealed class UdfOnDiskStructureTests { + + private const int BlockSize = 2048; + private const int PartitionStart = 257; + + private static byte[] Build(params (string Name, byte[] Data)[] files) { + var writer = new UdfWriter(); + foreach (var (name, data) in files) + writer.AddFile(name, data); + using var image = new MemoryStream(); + writer.WriteTo(image); + return image.ToArray(); + } + + private static ReadOnlySpan Block(byte[] image, int block) + => image.AsSpan(block * BlockSize, BlockSize); + + /// The root directory's File Entry, at partition block 1. + private static ReadOnlySpan RootFileEntry(byte[] image) => Block(image, PartitionStart + 1); + + /// + /// A name whose characters all fit in a byte is recorded with compression 8 + /// and one byte per character. Recording UTF-8 under that identifier makes + /// every accented name unreadable to anything but the writer that produced + /// it — OSTA UDF §2.1.1. + /// + [Test] + public void ALatin1NameIsRecordedOneBytePerCharacter() { + var image = Build(("café.bin", "x"u8.ToArray())); + var fids = DirectoryBytes(image, RootFileEntry(image)); + + // The parent record comes first and carries no identifier. + var identifier = FirstNamedIdentifier(fids); + Assert.Multiple(() => { + Assert.That(identifier[0], Is.EqualTo(8), "characters below U+0100 take the single-byte compression"); + Assert.That(identifier.Length, Is.EqualTo(1 + "café.bin".Length)); + Assert.That(identifier[^5], Is.EqualTo(0xE9), "é is one byte, its own code point, not two UTF-8 bytes"); + }); + } + + /// A name that needs more than a byte per character takes the wide compression. + [Test] + public void AWideNameIsRecordedTwoBytesPerCharacterBigEndian() { + var image = Build(("日本.bin", "x"u8.ToArray())); + var identifier = FirstNamedIdentifier(DirectoryBytes(image, RootFileEntry(image))); + + Assert.Multiple(() => { + Assert.That(identifier[0], Is.EqualTo(16)); + Assert.That(identifier.Length, Is.EqualTo(1 + 2 * "日本.bin".Length)); + Assert.That(identifier[1], Is.EqualTo(0x65), "big-endian: the high byte of U+65E5 comes first"); + Assert.That(identifier[2], Is.EqualTo(0xE5)); + }); + } + + /// + /// A directory is a dense run of File Identifier Descriptors. ECMA-167 + /// §4/14.4 lets one span a logical block boundary and the kernel's udf driver + /// relies on that; padding the boundary instead leaves a zero tag where the + /// next record should be, and the driver stops there. + /// + [Test] + public void DirectoryRecordsRunBackToBackAcrossBlockBoundaries() { + // Enough entries that the directory needs several blocks, so at least one + // record has to straddle a boundary. + var files = Enumerable.Range(0, 200) + .Select(i => ($"entry_{i:D4}.txt", "x"u8.ToArray())) + .ToArray(); + var image = Build(files); + var fids = DirectoryBytes(image, RootFileEntry(image)); + + var straddling = 0; + var position = 0; + var records = 0; + while (position + 38 <= fids.Length) { + var tag = BinaryPrimitives.ReadUInt16LittleEndian(fids.AsSpan(position)); + Assert.That(tag, Is.EqualTo(257), + $"byte {position} of the directory is not the start of a File Identifier Descriptor"); + var implementationUse = BinaryPrimitives.ReadUInt16LittleEndian(fids.AsSpan(position + 36)); + var length = (38 + implementationUse + fids[position + 19] + 3) & ~3; + + Assert.That(BinaryPrimitives.ReadUInt16LittleEndian(fids.AsSpan(position + 16)), Is.EqualTo(1), + "OSTA UDF §2.3.4.1: the file version number of every record is one"); + Assert.That(BinaryPrimitives.ReadUInt32LittleEndian(fids.AsSpan(position + 12)), + Is.EqualTo((uint)(RootDirectoryFirstBlock(image) + position / BlockSize)), + "a record's tag names the block it starts in"); + + if (position / BlockSize != (position + length - 1) / BlockSize) + ++straddling; + position += length; + ++records; + } + + Assert.Multiple(() => { + Assert.That(records, Is.EqualTo(files.Length + 1), "one record per entry, plus the parent"); + Assert.That(position, Is.EqualTo(fids.Length), "the records fill the directory exactly"); + Assert.That(straddling, Is.GreaterThan(0), + "with this many entries at least one record has to cross a block boundary"); + }); + } + + /// + /// A zero-length file is given no extent. An allocation descriptor naming a + /// block it does not own is a chain longer than the size says, which is what + /// every filesystem's own checker calls corruption. + /// + [Test] + public void AnEmptyFileIsGivenNoExtent() { + var image = Build(("empty.bin", []), ("full.bin", new byte[3000])); + + var empty = FileEntryFor(image, "empty.bin"); + var full = FileEntryFor(image, "full.bin"); + + Assert.Multiple(() => { + Assert.That(BinaryPrimitives.ReadUInt64LittleEndian(image.AsSpan(empty + 56)), Is.Zero, + "information length"); + Assert.That(BinaryPrimitives.ReadUInt32LittleEndian(image.AsSpan(empty + 172)), Is.Zero, + "an empty file records no allocation descriptors at all"); + Assert.That(BinaryPrimitives.ReadUInt64LittleEndian(image.AsSpan(empty + 64)), Is.Zero, + "and no blocks recorded"); + + Assert.That(BinaryPrimitives.ReadUInt32LittleEndian(image.AsSpan(full + 172)), Is.EqualTo(8u), + "a file with content records one descriptor"); + Assert.That(BinaryPrimitives.ReadUInt32LittleEndian(image.AsSpan(full + 176)) & 0x3FFFFFFF, + Is.EqualTo(3000u), "whose length is the file's, not the block it was rounded up to"); + }); + } + + /// + /// ECMA-167 §3/8.4 wants an anchor at logical block 256 and at the volume's + /// last block, and each says which block it is. A volume with only the first + /// has one copy of the only descriptor found by address rather than by being + /// pointed at. + /// + [Test] + public void BothAnchorsAreRecordedAndNameTheirOwnBlock() { + var image = Build(("a.bin", "a"u8.ToArray())); + var lastBlock = image.Length / BlockSize - 1; + + Assert.Multiple(() => { + foreach (var block in new[] { 256, lastBlock }) { + var tag = Block(image, block); + Assert.That(BinaryPrimitives.ReadUInt16LittleEndian(tag), Is.EqualTo(2), + $"no anchor at block {block}"); + Assert.That(BinaryPrimitives.ReadUInt32LittleEndian(tag[12..]), Is.EqualTo((uint)block), + $"the anchor at block {block} names a different block"); + byte sum = 0; + for (var i = 0; i < 16; ++i) + if (i != 4) sum = (byte)(sum + tag[i]); + Assert.That(sum, Is.EqualTo(tag[4]), $"the anchor at block {block} has a broken tag checksum"); + } + }); + } + + /// + /// Both volume descriptor sequences the anchor names are recorded, each + /// carrying the descriptors a UDF volume is required to have. + /// + [Test] + public void BothVolumeDescriptorSequencesAreRecorded() { + var image = Build(("a.bin", "a"u8.ToArray())); + var anchor = Block(image, 256); + var main = (int)BinaryPrimitives.ReadUInt32LittleEndian(anchor[20..]); + var reserve = (int)BinaryPrimitives.ReadUInt32LittleEndian(anchor[28..]); + + Assert.That(reserve, Is.Not.Zero, "the anchor records no reserve sequence"); + Assert.Multiple(() => { + foreach (var start in new[] { main, reserve }) { + var tags = new List(); + for (var i = 0; i < 16; ++i) { + var tag = BinaryPrimitives.ReadUInt16LittleEndian(Block(image, start + i)); + if (tag == 0) break; + tags.Add(tag); + if (tag == 8) break; + } + + // Primary volume, logical volume, partition, implementation use, + // unallocated space, terminator. + Assert.That(tags, Is.EquivalentTo(new ushort[] { 1, 6, 5, 4, 7, 8 }), + $"the sequence at block {start} is missing descriptors"); + } + }); + } + + // ── walking helpers ─────────────────────────────────────────────────────── + + private static int RootDirectoryFirstBlock(byte[] image) + => (int)BinaryPrimitives.ReadUInt32LittleEndian(RootFileEntry(image)[180..]); + + /// Concatenates the blocks a directory File Entry's descriptors name. + private static byte[] DirectoryBytes(byte[] image, ReadOnlySpan fileEntry) { + var informationLength = (int)BinaryPrimitives.ReadUInt64LittleEndian(fileEntry[56..]); + var lengthOfDescriptors = (int)BinaryPrimitives.ReadUInt32LittleEndian(fileEntry[172..]); + + var bytes = new List(); + for (var at = 176; at + 8 <= 176 + lengthOfDescriptors; at += 8) { + var length = (int)(BinaryPrimitives.ReadUInt32LittleEndian(fileEntry[at..]) & 0x3FFFFFFF); + var block = (int)BinaryPrimitives.ReadUInt32LittleEndian(fileEntry[(at + 4)..]); + for (var i = 0; i < length; ++i) + bytes.Add(image[(PartitionStart + block) * BlockSize + i]); + } + + return [.. bytes.Take(informationLength)]; + } + + /// The identifier bytes of the first record that names something. + private static byte[] FirstNamedIdentifier(byte[] directory) { + var position = 0; + while (position + 38 <= directory.Length) { + var implementationUse = BinaryPrimitives.ReadUInt16LittleEndian(directory.AsSpan(position + 36)); + var identifierLength = directory[position + 19]; + if (identifierLength > 0) + return directory[(position + 38 + implementationUse)..(position + 38 + implementationUse + identifierLength)]; + position += (38 + implementationUse + identifierLength + 3) & ~3; + } + + throw new InvalidOperationException("the directory holds no named record"); + } + + /// Byte offset of the File Entry the root directory names . + private static int FileEntryFor(byte[] image, string name) { + var directory = DirectoryBytes(image, RootFileEntry(image)); + var position = 0; + while (position + 38 <= directory.Length) { + var implementationUse = BinaryPrimitives.ReadUInt16LittleEndian(directory.AsSpan(position + 36)); + var identifierLength = directory[position + 19]; + if (identifierLength > 1) { + var text = Encoding.Latin1.GetString( + directory, position + 39 + implementationUse, identifierLength - 1); + if (text == name) { + var block = BinaryPrimitives.ReadUInt32LittleEndian(directory.AsSpan(position + 24)); + return (int)((PartitionStart + block) * BlockSize); + } + } + + position += (38 + implementationUse + identifierLength + 3) & ~3; + } + + throw new InvalidOperationException($"the root directory holds no entry named {name}"); + } +} diff --git a/Compression.Tests/Udf/UdfTests.cs b/Compression.Tests/Udf/UdfTests.cs index 2bee50c59..86aaf065e 100644 --- a/Compression.Tests/Udf/UdfTests.cs +++ b/Compression.Tests/Udf/UdfTests.cs @@ -56,6 +56,12 @@ private static byte[] BuildMinimalUdf(params (string Name, byte[] Data)[] files) // Main VDS extent: length at offset 16, location at offset 20 BinaryPrimitives.WriteUInt32LittleEndian(avdp[16..], 4 * (uint)sectorSize); // 4 sectors BinaryPrimitives.WriteUInt32LittleEndian(avdp[20..], 32); // starts at sector 32 + // The anchor is the one descriptor a reader finds by address rather than by + // being pointed at, so it has to say which block it is and carry a valid + // ECMA-167 §7.2 checksum — that pair is what separates a real anchor from + // file data that happens to start with the same two bytes. + BinaryPrimitives.WriteUInt32LittleEndian(avdp[12..], 256); + SealTagChecksum(img, 256 * sectorSize); // Partition Descriptor at sector 32 (tag 5) var pd = img.AsSpan(32 * sectorSize); @@ -155,6 +161,22 @@ private static byte[] BuildMinimalUdf(params (string Name, byte[] Data)[] files) return img; } + /// + /// Writes the ECMA-167 §7.2 TagChecksum of the descriptor tag at + /// : the sum modulo 256 of the tag's other fifteen + /// bytes. + /// + private static void SealTagChecksum(byte[] image, int offset) { + image[offset + 4] = 0; + byte sum = 0; + for (var i = 0; i < 16; ++i) { + if (i == 4) continue; + sum = (byte)(sum + image[offset + i]); + } + + image[offset + 4] = sum; + } + [Test, Category("HappyPath")] public void Read_SingleFile() { var content = "Hello UDF!"u8.ToArray(); @@ -286,8 +308,9 @@ public void Writer_HasNsrMagic() { using var ms = new MemoryStream(); w.WriteTo(ms); var bytes = ms.ToArray(); - // NSR02 at sector 17, offset 1 - Assert.That(Encoding.ASCII.GetString(bytes, 17 * 2048 + 1, 5), Is.EqualTo("NSR02")); + // NSR03 at sector 17, offset 1: the writer records UDF 2.01, whose volume + // recognition sequence names the third-generation structure. + Assert.That(Encoding.ASCII.GetString(bytes, 17 * 2048 + 1, 5), Is.EqualTo("NSR03")); } // ── Descriptor tag CRC-16 (ECMA-167 §7.2.1) validation ────────────────── diff --git a/Compression.Tests/Udf/UdfWipeEmptyTests.cs b/Compression.Tests/Udf/UdfWipeEmptyTests.cs index 1b38ac21c..a385c449c 100644 --- a/Compression.Tests/Udf/UdfWipeEmptyTests.cs +++ b/Compression.Tests/Udf/UdfWipeEmptyTests.cs @@ -40,8 +40,9 @@ public void WipeEmpty_ZerosClusterTip_AndFileRoundTrips() { var fileExtent = UdfExtentMap.Enumerate(ms) .First(e => e.Kind == DefragBlockKind.Used && e.FileName == "data.bin" && e.Classification != DefragBlockClass.Directory); - Assert.That(fileExtent.Length, Is.GreaterThan(content.Length), - "The data extent is sector-padded, so a tip exists beyond the logical size"); + Assert.That(fileExtent.Length, Is.EqualTo(content.Length), + "The allocation descriptor records the logical length, so the extent stops there " + + "and the rest of the sector is the tip"); // Dirty the cluster tip (immediately after the file's logical end). var tipOffset = fileExtent.Offset + content.Length; diff --git a/FileSystems/FileSystem.Udf/OstaCompressedUnicode.cs b/FileSystems/FileSystem.Udf/OstaCompressedUnicode.cs new file mode 100644 index 000000000..78b17ddf5 --- /dev/null +++ b/FileSystems/FileSystem.Udf/OstaCompressedUnicode.cs @@ -0,0 +1,108 @@ +using System.Text; + +namespace FileSystem.Udf; + +/// +/// OSTA Compressed Unicode (CS0), the character set every UDF identifier is +/// recorded in — file names in File Identifier Descriptors and the dstrings of +/// the volume, logical-volume and file-set descriptors. +/// +/// +/// +/// A CS0 byte string starts with a compression identifier and continues with +/// the characters themselves. Compression 8 means one byte per character, each +/// byte being the whole Unicode code point, so the representable range is +/// U+0000..U+00FF. Compression 16 means two big-endian bytes per character. +/// The encoder picks 8 when every character fits in a byte and 16 otherwise — +/// OSTA UDF §2.1.1. +/// +/// +/// Compression 8 is not UTF-8. Reading it as UTF-8 turns every accented Latin-1 +/// name a native tool wrote into replacement characters, and writing UTF-8 +/// under the identifier 8 produces names no other implementation can read. +/// +/// +internal static class OstaCompressedUnicode { + + /// Compression identifier for one byte per character. + public const byte SingleByte = 8; + + /// Compression identifier for two big-endian bytes per character. + public const byte DoubleByte = 16; + + /// + /// Decodes a CS0 byte string, compression identifier included. An empty span, + /// or one holding nothing but the identifier, decodes to the empty string. + /// + public static string Decode(ReadOnlySpan raw) { + if (raw.Length <= 1) + return string.Empty; + + var body = raw[1..]; + var text = raw[0] switch { + SingleByte => Encoding.Latin1.GetString(body), + DoubleByte => Encoding.BigEndianUnicode.GetString(body[..(body.Length & ~1)]), + // No other identifier is defined for UDF identifiers; treating the whole + // field as single-byte characters is the least destructive reading. + _ => Encoding.Latin1.GetString(raw), + }; + + return text.TrimEnd('\0'); + } + + /// + /// Encodes as a CS0 byte string, choosing the + /// narrower compression whenever every character fits in a single byte. + /// Returns an empty array for an empty string: a zero-length identifier is + /// recorded with no compression byte at all. + /// + public static byte[] Encode(string text) { + ArgumentNullException.ThrowIfNull(text); + if (text.Length == 0) + return []; + + var singleByte = true; + foreach (var c in text) + if (c > 0xFF) { + singleByte = false; + break; + } + + if (singleByte) { + var result = new byte[1 + text.Length]; + result[0] = SingleByte; + for (var i = 0; i < text.Length; ++i) + result[i + 1] = (byte)text[i]; + return result; + } + + var wide = Encoding.BigEndianUnicode.GetBytes(text); + var buffer = new byte[1 + wide.Length]; + buffer[0] = DoubleByte; + wide.CopyTo(buffer, 1); + return buffer; + } + + /// + /// Writes an ECMA-167 §1/7.2.12 dstring: a CS0 byte string left-aligned in a + /// fixed-width field whose final byte records how many bytes of it are used. + /// The text is truncated on a character boundary when it will not fit. + /// + public static void WriteDString(byte[] buffer, int offset, int fieldLength, string text) { + ArgumentNullException.ThrowIfNull(buffer); + Array.Clear(buffer, offset, fieldLength); + if (string.IsNullOrEmpty(text)) + return; + + var encoded = Encode(text); + // The length byte occupies the last position, so at most fieldLength-1 bytes + // of characters fit; drop whole characters until they do. + var characterBytes = encoded[0] == DoubleByte ? 2 : 1; + var usable = (fieldLength - 1 - 1) / characterBytes * characterBytes + 1; + if (encoded.Length > usable) + encoded = encoded[..usable]; + + encoded.CopyTo(buffer, offset); + buffer[offset + fieldLength - 1] = (byte)encoded.Length; + } +} diff --git a/FileSystems/FileSystem.Udf/UdfBlockMover.cs b/FileSystems/FileSystem.Udf/UdfBlockMover.cs index bf7056c98..f0ef5ec82 100644 --- a/FileSystems/FileSystem.Udf/UdfBlockMover.cs +++ b/FileSystems/FileSystem.Udf/UdfBlockMover.cs @@ -1,7 +1,6 @@ #pragma warning disable CS1591 using System.Buffers; using System.Buffers.Binary; -using System.Text; using Compression.Core.Checksums; using Compression.Core.Layout; using Compression.Registry; @@ -163,10 +162,7 @@ private void IndexDirectory(Stream image, SectorCache cache, int partitionStart, /// Decodes a file identifier, which names its own character set in its first byte. private static string ReadFileIdentifier(byte[] bytes, int at, int length) { if (at + length > bytes.Length) return string.Empty; - var name = length > 1 && bytes[at] == 8 ? Encoding.UTF8.GetString(bytes, at + 1, length - 1) - : length > 1 && bytes[at] == 16 ? Encoding.BigEndianUnicode.GetString(bytes, at + 1, length - 1) - : Encoding.ASCII.GetString(bytes, at, length); - return name.TrimEnd('\0'); + return OstaCompressedUnicode.Decode(bytes.AsSpan(at, length)); } /// @@ -264,14 +260,7 @@ private static int FindFileIcbStream(Stream image, SectorCache cache, int partSt if (!isParent && !isDeleted && idLen > 0) { var nameStart = pos + 38 + lIu; - string name; - if (idLen > 1 && dirBytes[nameStart] == 8) - name = Encoding.UTF8.GetString(dirBytes, nameStart + 1, idLen - 1); - else if (idLen > 1 && dirBytes[nameStart] == 16) - name = Encoding.BigEndianUnicode.GetString(dirBytes, nameStart + 1, idLen - 1); - else - name = Encoding.ASCII.GetString(dirBytes, nameStart, idLen); - name = name.TrimEnd('\0'); + var name = OstaCompressedUnicode.Decode(dirBytes.AsSpan(nameStart, idLen)); if (name.Equals(targetName, StringComparison.OrdinalIgnoreCase) || targetName.Equals("*", StringComparison.Ordinal)) diff --git a/FileSystems/FileSystem.Udf/UdfDescriptors.cs b/FileSystems/FileSystem.Udf/UdfDescriptors.cs new file mode 100644 index 000000000..403abe96c --- /dev/null +++ b/FileSystems/FileSystem.Udf/UdfDescriptors.cs @@ -0,0 +1,157 @@ +using System.Buffers.Binary; +using System.Text; +using Compression.Core.Checksums; + +namespace FileSystem.Udf; + +/// +/// The small ECMA-167 building blocks every UDF descriptor is assembled from: +/// the descriptor tag, the entity identifier, the character specification and +/// the timestamp. +/// +internal static class UdfDescriptors { + + /// + /// Descriptor version stamped into every tag. ECMA-167 defines 2; OSTA UDF + /// §2.2.1.2 requires 3 from revision 2.00 onwards, and the volumes this + /// writer produces declare 2.01. + /// + public const ushort DescriptorVersion = 3; + + /// Revision this writer records, little-endian BCD as UDF stores it. + public const ushort UdfRevision = 0x0201; + + /// + /// Suffix of the *OSTA UDF Compliant domain entity identifier + /// (OSTA UDF §2.1.5.3): UDF revision, domain flags, five reserved bytes. + /// + public static readonly byte[] DomainSuffix = + [UdfRevision & 0xFF, UdfRevision >> 8, 0, 0, 0, 0, 0, 0]; + + /// + /// Suffix of a UDF-defined entity identifier (OSTA UDF §2.1.5.3): UDF + /// revision, operating-system class, operating-system identifier, reserved. + /// Class 0 is "undefined", which is what a portable writer can honestly claim. + /// + public static readonly byte[] UdfEntitySuffix = + [UdfRevision & 0xFF, UdfRevision >> 8, 0, 0, 0, 0, 0, 0]; + + /// + /// Suffix of an implementation entity identifier (OSTA UDF §2.1.5.3): + /// operating-system class, operating-system identifier, six free bytes. + /// + public static readonly byte[] ImplementationSuffix = [0, 0, 0, 0, 0, 0, 0, 0]; + + /// + /// Names the UDF implementation rather than the product, so an image says + /// which filesystem code shaped it whichever tool drove that code. + /// + public const string ImplementationId = "*Linux UDFFS"; + + /// + /// Recording timestamp stamped into every descriptor. A fixed instant keeps + /// two runs over the same input byte-identical, which the streaming writer's + /// contract with the buffered one depends on. + /// + public static readonly DateTime RecordingTime = new(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + /// + /// Writes the fixed part of an ECMA-167 §7.2 descriptor tag. The checksum, + /// CRC and CRC length are filled in by once the + /// body exists. + /// + public static void WriteTag(byte[] buffer, int offset, ushort tagIdentifier, uint tagLocation) { + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(offset), tagIdentifier); + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(offset + 2), DescriptorVersion); + buffer[offset + 4] = 0; // TagChecksum + buffer[offset + 5] = 0; // Reserved + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(offset + 6), 1); // TagSerialNumber + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(offset + 12), tagLocation); + } + + /// + /// Completes an ECMA-167 §7.2 descriptor tag: the CRC-16/CCITT (init 0, + /// polynomial 0x1021, non-reflected) over + /// bytes following the tag, that length, and finally the byte-sum-mod-256 + /// checksum over the tag's other fifteen bytes. + /// + public static void FinalizeTag(byte[] buffer, int tagOffset, int bodyLength) { + var bodyStart = tagOffset + 16; + if (bodyStart + bodyLength > buffer.Length) + bodyLength = buffer.Length - bodyStart; + if (bodyLength < 0) + bodyLength = 0; + + var crc = Crc16Ccitt.Compute(buffer.AsSpan(bodyStart, bodyLength)); + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(tagOffset + 8), crc); + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(tagOffset + 10), (ushort)bodyLength); + + buffer[tagOffset + 4] = 0; + byte sum = 0; + for (var i = 0; i < 16; ++i) { + if (i == 4) continue; + sum = (byte)(sum + buffer[tagOffset + i]); + } + + buffer[tagOffset + 4] = sum; + } + + /// + /// Writes an ECMA-167 §1/7.4 entity identifier: one flags byte, a 23-byte + /// identifier and an 8-byte suffix whose shape depends on which kind of + /// identifier this is. + /// + public static void WriteEntityId(byte[] buffer, int offset, string identifier, ReadOnlySpan suffix) { + Array.Clear(buffer, offset, 32); + var bytes = Encoding.ASCII.GetBytes(identifier); + Array.Copy(bytes, 0, buffer, offset + 1, Math.Min(bytes.Length, 23)); + if (!suffix.IsEmpty) + suffix[..Math.Min(suffix.Length, 8)].CopyTo(buffer.AsSpan(offset + 24, 8)); + } + + /// + /// Writes an ECMA-167 §1/7.2.1 character specification naming OSTA + /// Compressed Unicode, the only character set UDF volumes use. + /// + public static void WriteCharacterSet(byte[] buffer, int offset) { + Array.Clear(buffer, offset, 64); + buffer[offset] = 0; + Encoding.ASCII.GetBytes("OSTA Compressed Unicode").CopyTo(buffer, offset + 1); + } + + /// Writes an ECMA-167 §1/7.3 timestamp, recorded as UTC. + public static void WriteTimestamp(byte[] buffer, int offset, DateTime utc) { + // Type 1 is "local time", and with a zero offset that is UTC. Type 0 would + // mean the interpretation is not specified at all. + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(offset), 1 << 12); + BinaryPrimitives.WriteInt16LittleEndian(buffer.AsSpan(offset + 2), (short)utc.Year); + buffer[offset + 4] = (byte)utc.Month; + buffer[offset + 5] = (byte)utc.Day; + buffer[offset + 6] = (byte)utc.Hour; + buffer[offset + 7] = (byte)utc.Minute; + buffer[offset + 8] = (byte)utc.Second; + buffer[offset + 9] = 0; + buffer[offset + 10] = 0; + buffer[offset + 11] = 0; + } + + /// Writes an ECMA-167 §1/7.1 extent descriptor: byte length, then block. + public static void WriteExtent(byte[] buffer, int offset, uint length, uint location) { + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(offset), length); + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(offset + 4), location); + } + + /// + /// Writes an ECMA-167 §4/14.14.2 long allocation descriptor addressing + /// bytes at of the first + /// partition, with in the UDF-defined part of + /// its implementation-use area (OSTA UDF §2.3.4.3). + /// + public static void WriteLongAd(byte[] buffer, int offset, uint length, uint block, uint uniqueId = 0) { + Array.Clear(buffer, offset, 16); + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(offset), length); + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(offset + 4), block); + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(offset + 8), 0); // partition reference + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(offset + 12), uniqueId); + } +} diff --git a/FileSystems/FileSystem.Udf/UdfExtentMap.cs b/FileSystems/FileSystem.Udf/UdfExtentMap.cs index 127c8fb53..2d0399885 100644 --- a/FileSystems/FileSystem.Udf/UdfExtentMap.cs +++ b/FileSystems/FileSystem.Udf/UdfExtentMap.cs @@ -70,10 +70,26 @@ public static IEnumerable Enumerate(Stream image) { var mainVdsLoc = BinaryPrimitives.ReadUInt32LittleEndian(sectorBuf.AsSpan(20)); var mainVdsLen = BinaryPrimitives.ReadUInt32LittleEndian(sectorBuf.AsSpan(16)); + var reserveVdsLoc = BinaryPrimitives.ReadUInt32LittleEndian(sectorBuf.AsSpan(28)); + var reserveVdsLen = BinaryPrimitives.ReadUInt32LittleEndian(sectorBuf.AsSpan(24)); + + // A second anchor sits in the volume's last block (ECMA-167 §3/8.4). Left + // uncovered it reads as free space and the wiper zeroes it, which costs the + // volume the redundancy the standard asks for. + var lastBlock = image.Length / SectorSize - 1; + if (lastBlock > AvdpSector) { + var tailOff = lastBlock * SectorSize; + cache.Read(tailOff, sectorBuf); + if (BinaryPrimitives.ReadUInt16LittleEndian(sectorBuf) == 2) + yield return new DefragBlockInfo(tailOff, SectorSize, DefragBlockKind.MetadataReserved, + FileName: "UDF AVDP"); + } // Walk VDS to find PD (5) and LVD (6). int partStart = 0; int fsdLbn = 0; + long lvidLoc = 0; + long lvidLen = 0; var vdsSectors = (int)(mainVdsLen / SectorSize); for (var i = 0; i < vdsSectors && i < 64; i++) { var off = (long)(mainVdsLoc + i) * SectorSize; @@ -87,9 +103,33 @@ public static IEnumerable Enumerate(Stream image) { partStart = (int)BinaryPrimitives.ReadUInt32LittleEndian(sectorBuf.AsSpan(188)); } else if (tagId == 6) { fsdLbn = (int)BinaryPrimitives.ReadUInt32LittleEndian(sectorBuf.AsSpan(252)); + lvidLen = BinaryPrimitives.ReadUInt32LittleEndian(sectorBuf.AsSpan(432)); + lvidLoc = BinaryPrimitives.ReadUInt32LittleEndian(sectorBuf.AsSpan(436)); } else if (tagId == 8) break; // terminator } + // The reserve sequence the anchor names, and the logical volume integrity + // sequence the logical volume descriptor names, are both real structures + // outside the partition. + var reserveSectors = (int)(reserveVdsLen / SectorSize); + for (var i = 0; i < reserveSectors && i < 64; i++) { + var off = (long)(reserveVdsLoc + i) * SectorSize; + if (off + SectorSize > image.Length) break; + cache.Read(off, sectorBuf); + var tagId = BinaryPrimitives.ReadUInt16LittleEndian(sectorBuf); + yield return new DefragBlockInfo(off, SectorSize, DefragBlockKind.MetadataReserved, + FileName: $"UDF reserve VDS tag={tagId}"); + if (tagId == 8) break; + } + + var lvidSectors = (int)(lvidLen / SectorSize); + for (var i = 0; i < lvidSectors && i < 64; i++) { + var off = (lvidLoc + i) * SectorSize; + if (off + SectorSize > image.Length) break; + yield return new DefragBlockInfo(off, SectorSize, DefragBlockKind.MetadataReserved, + FileName: "UDF LVID"); + } + // Read FSD. var fsdOffset = (long)(partStart + fsdLbn) * SectorSize; if (fsdOffset + SectorSize > image.Length) yield break; @@ -214,14 +254,7 @@ private static IEnumerable WalkDirectory(Stream image, SectorCa if (!isParent && !isDeleted && fidIdLen > 0) { var nameStart = pos + 38 + lIu; - string name; - if (fidIdLen > 1 && dirBytes[nameStart] == 8) - name = Encoding.UTF8.GetString(dirBytes, nameStart + 1, fidIdLen - 1); - else if (fidIdLen > 1 && dirBytes[nameStart] == 16) - name = Encoding.BigEndianUnicode.GetString(dirBytes, nameStart + 1, fidIdLen - 1); - else - name = Encoding.ASCII.GetString(dirBytes, nameStart, fidIdLen); - name = name.TrimEnd('\0'); + var name = OstaCompressedUnicode.Decode(dirBytes.AsSpan(nameStart, fidIdLen)); var fullPath = string.IsNullOrEmpty(basePath) ? name : $"{basePath}/{name}"; diff --git a/FileSystems/FileSystem.Udf/UdfFormatDescriptor.cs b/FileSystems/FileSystem.Udf/UdfFormatDescriptor.cs index c60ee2d7d..34df3e770 100644 --- a/FileSystems/FileSystem.Udf/UdfFormatDescriptor.cs +++ b/FileSystems/FileSystem.Udf/UdfFormatDescriptor.cs @@ -111,9 +111,31 @@ public void UpdateAllocationAfterMove(Stream image, string fileName, long oldOff /// public string AcceptedInputsDescription => "UDF 2.01 disc image; any files, flat directory."; /// - /// Performs the can accept operation. + /// Accepts any file the writer can address. Short allocation descriptors cap + /// an extent below 2^30 bytes (OSTA UDF §2.3.10.1) and only so many of them + /// fit in one File Entry, so a file past that ceiling is declined here rather + /// than written as a volume nothing can read. /// - public bool CanAccept(ArchiveInputInfo input, out string? reason) { reason = null; return true; } + public bool CanAccept(ArchiveInputInfo input, out string? reason) { + reason = null; + if (input is null || input.IsDirectory) + return true; + + long size; + if (input.InMemoryContent is { } content) + size = content.LongLength; + else if (File.Exists(input.FullPath)) + size = new FileInfo(input.FullPath).Length; + else + return true; + + if (size <= UdfWriter.MaxFileBytes) + return true; + + reason = $"UDF addresses at most {UdfWriter.MaxFileBytes:N0} bytes per file without " + + "allocation descriptor continuation, which this writer does not record."; + return false; + } /// /// Gets the id. diff --git a/FileSystems/FileSystem.Udf/UdfModifier.cs b/FileSystems/FileSystem.Udf/UdfModifier.cs index 546ee6ba1..321d6354b 100644 --- a/FileSystems/FileSystem.Udf/UdfModifier.cs +++ b/FileSystems/FileSystem.Udf/UdfModifier.cs @@ -1,6 +1,5 @@ #pragma warning disable CS1591 using System.Buffers.Binary; -using System.Text; using Compression.Core.Checksums; namespace FileSystem.Udf; @@ -18,7 +17,11 @@ namespace FileSystem.Udf; /// The root directory's FID extent — appended into trailing sector padding, /// or extended via a second short_ad on the root File Entry. /// The root File Entry sector — info length and L_AD updated, tag re-CRC'd. -/// The Partition Descriptor sector — partition length grown. +/// Every Partition Descriptor — the main sequence's and the reserve +/// sequence's copy — grown to the partition's new length. +/// The volume's second anchor, re-recorded in its new last block. +/// The Logical Volume Integrity Descriptor — size, free space and the +/// file count it advertises. /// /// /// Remove uses the FID Characteristics "deleted" flag (bit 2 = 0x04) per @@ -62,8 +65,10 @@ public static void AddFile(Stream image, string name, byte[] data) { var ctx = ReadContext(image); - // Compute new file's allocation: FE sector + data sectors, all at partition tail. - var dataSectors = data.Length == 0 ? 1 : (data.Length + SectorSize - 1) / SectorSize; + // Compute new file's allocation: FE sector + data sectors, all at partition + // tail. A zero-length file is given no data block: an allocation descriptor + // naming a block it does not own is a chain longer than the size says. + var dataSectors = (data.Length + SectorSize - 1) / SectorSize; var feLbn = ctx.HighWaterLbn; var dataLbn = feLbn + 1; var newHighWater = dataLbn + dataSectors; @@ -72,12 +77,14 @@ public static void AddFile(Stream image, string name, byte[] data) { var fid = BuildFid(flags: 0x00, icbLbn: feLbn, name); // Write file data (zero-padded to sector boundary). - var dataAbs = (ctx.PartitionStart + dataLbn) * (long)SectorSize; - EnsureLength(image, dataAbs + dataSectors * (long)SectorSize); - image.Position = dataAbs; - image.Write(data); - var dataPad = dataSectors * SectorSize - data.Length; - if (dataPad > 0) image.Write(new byte[dataPad]); + if (dataSectors > 0) { + var dataAbs = (ctx.PartitionStart + dataLbn) * (long)SectorSize; + EnsureLength(image, dataAbs + dataSectors * (long)SectorSize); + image.Position = dataAbs; + image.Write(data); + var dataPad = dataSectors * SectorSize - data.Length; + if (dataPad > 0) image.Write(new byte[dataPad]); + } // Write file File Entry sector. var feSector = BuildFileFe(feLbn, data.Length, dataLbn); @@ -88,6 +95,7 @@ public static void AddFile(Stream image, string name, byte[] data) { // Grow the partition descriptor and image to cover the new allocations. UpdatePartitionLength(image, ctx, newHighWater); + AdjustFileCount(image, ctx, +1); } /// @@ -134,6 +142,7 @@ public static bool RemoveFile(Stream image, string name, bool wipeData = true) { WipeFileEntry(image, ctx, hit.IcbLbn); } + AdjustFileCount(image, ctx, -1); return true; } @@ -142,10 +151,15 @@ public static bool RemoveFile(Stream image, string name, bool wipeData = true) { private sealed record Context( int PartitionStart, int PartitionLengthSectors, - int PdSectorLba, + IReadOnlyList PdSectorLbas, int RootFeLbn, int HighWaterLbn, - long ImageSize); + int LvidLba, + long ImageSize) { + + /// The main sequence's partition descriptor, which always exists. + public int PdSectorLba => this.PdSectorLbas[0]; + } private static Context ReadContext(Stream image) { if (image.Length < 257L * SectorSize) @@ -157,28 +171,46 @@ private static Context ReadContext(Stream image) { throw new InvalidDataException("UDF: invalid AVDP tag."); var mainVdsLoc = (int)BinaryPrimitives.ReadUInt32LittleEndian(avdp.AsSpan(20)); var mainVdsLen = (int)BinaryPrimitives.ReadUInt32LittleEndian(avdp.AsSpan(16)); + var reserveVdsLoc = (int)BinaryPrimitives.ReadUInt32LittleEndian(avdp.AsSpan(28)); + var reserveVdsLen = (int)BinaryPrimitives.ReadUInt32LittleEndian(avdp.AsSpan(24)); // Walk VDS for PD and LVD - int partStart = 0, partLen = 0, pdSectorLba = 0; - int fsdLbn = 0; - var vdsSectors = mainVdsLen / SectorSize; - for (var i = 0; i < vdsSectors && i < 64; i++) { - var sectorLba = mainVdsLoc + i; - if ((long)(sectorLba + 1) * SectorSize > image.Length) break; - var sec = ReadSector(image, sectorLba); - var tag = BinaryPrimitives.ReadUInt16LittleEndian(sec); - if (tag == PdTagId) { - partStart = (int)BinaryPrimitives.ReadUInt32LittleEndian(sec.AsSpan(188)); - partLen = (int)BinaryPrimitives.ReadUInt32LittleEndian(sec.AsSpan(192)); - pdSectorLba = sectorLba; - } else if (tag == LvdTagId) { - fsdLbn = (int)BinaryPrimitives.ReadUInt32LittleEndian(sec.AsSpan(252)); - } else if (tag == 8) { - break; + int partStart = 0, partLen = 0; + int fsdLbn = 0, lvidLba = 0; + var pdSectors = new List(); + + void Scan(int loc, int len, bool primary) { + var sectors = len / SectorSize; + for (var i = 0; i < sectors && i < 64; i++) { + var sectorLba = loc + i; + if ((long)(sectorLba + 1) * SectorSize > image.Length) break; + var sec = ReadSector(image, sectorLba); + var tag = BinaryPrimitives.ReadUInt16LittleEndian(sec); + if (tag == PdTagId) { + if (primary) { + partStart = (int)BinaryPrimitives.ReadUInt32LittleEndian(sec.AsSpan(188)); + partLen = (int)BinaryPrimitives.ReadUInt32LittleEndian(sec.AsSpan(192)); + } + pdSectors.Add(sectorLba); + } else if (tag == LvdTagId) { + if (primary) { + fsdLbn = (int)BinaryPrimitives.ReadUInt32LittleEndian(sec.AsSpan(252)); + lvidLba = (int)BinaryPrimitives.ReadUInt32LittleEndian(sec.AsSpan(436)); + } + } else if (tag == 8) { + break; + } } } - if (pdSectorLba == 0) + Scan(mainVdsLoc, mainVdsLen, primary: true); + // The reserve sequence carries the same partition descriptor. Growing the + // partition without growing its copy leaves a reader that falls back to the + // reserve seeing a volume that stops short of its own data. + if (reserveVdsLen > 0) + Scan(reserveVdsLoc, reserveVdsLen, primary: false); + + if (pdSectors.Count == 0) throw new InvalidDataException("UDF: partition descriptor not found."); // FSD → root ICB LBN @@ -192,9 +224,10 @@ private static Context ReadContext(Stream image) { return new Context( PartitionStart: partStart, PartitionLengthSectors: partLen, - PdSectorLba: pdSectorLba, + PdSectorLbas: pdSectors, RootFeLbn: rootLbn, HighWaterLbn: partLen, + LvidLba: lvidLba, ImageSize: image.Length); } @@ -339,15 +372,8 @@ private sealed record FidHit(int Offset, int Length, int IcbLbn); return null; } - private static string DecodeCs0(byte[] buf, int offset, int len) { - if (len <= 0) return ""; - var compressionId = buf[offset]; - if (compressionId == 8 && len > 1) - return Encoding.UTF8.GetString(buf, offset + 1, len - 1); - if (compressionId == 16 && len > 1) - return Encoding.BigEndianUnicode.GetString(buf, offset + 1, len - 1); - return Encoding.ASCII.GetString(buf, offset, len); - } + private static string DecodeCs0(byte[] buf, int offset, int len) + => len <= 0 ? "" : OstaCompressedUnicode.Decode(buf.AsSpan(offset, len)); // ── Root FID growth (Add path) ──────────────────────────────────────────── @@ -372,6 +398,10 @@ private static void AppendFidToRoot(Stream image, Context ctx, byte[] fid, ref i capacity += sectors * SectorSize; } + // The record starts wherever the directory's bytes currently end, and its + // tag has to name the logical block that position falls in. + StampFidLocation(fid, BlockAtDirectoryOffset(ext, oldLength)); + if (newLength <= capacity) { // Fits inside the trailing pad of the last extent's last sector. WriteFidIntoExistingExtent(image, ctx, ext, fid); @@ -382,6 +412,8 @@ private static void AppendFidToRoot(Stream image, Context ctx, byte[] fid, ref i // First, write any spillover into the existing tail (if some bytes still fit). var tailRoom = (int)(capacity - oldLength); var spilled = Math.Min(tailRoom, fid.Length); + if (spilled == 0) + StampFidLocation(fid, highWaterLbn); if (spilled > 0) { // Fill the tail of the last extent with the first `spilled` bytes. WriteFidPartialToExistingTail(image, ctx, ext, fid, 0, spilled); @@ -403,6 +435,22 @@ private static void AppendFidToRoot(Stream image, Context ctx, byte[] fid, ref i } } + /// + /// The logical block holding byte of a directory's + /// data, walking its extents in order. Returns -1 when the offset lies past + /// everything the extents cover. + /// + private static int BlockAtDirectoryOffset(FidExtent ext, long offset) { + foreach (var (lbn, len) in ext.Extents) { + var capacity = (long)((len + SectorSize - 1) / SectorSize) * SectorSize; + if (offset < capacity) + return lbn + (int)(offset / SectorSize); + offset -= capacity; + } + + return -1; + } + private static void WriteFidIntoExistingExtent(Stream image, Context ctx, FidExtent ext, byte[] fid) { // Last extent absorbs the new FID — bytes past its logical length but // within its sector capacity. @@ -518,24 +566,40 @@ private static byte[] BuildFileFe(int lbn, int fileSize, int dataLbn) { BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(44), (1u << 12) | (1u << 7) | (1u << 2)); BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(48), 1); BinaryPrimitives.WriteUInt64LittleEndian(buf.AsSpan(56), (ulong)fileSize); + var sectors = (fileSize + SectorSize - 1) / SectorSize; + BinaryPrimitives.WriteUInt64LittleEndian(buf.AsSpan(64), (ulong)sectors); // LogicalBlocksRecorded + UdfDescriptors.WriteTimestamp(buf, 72, UdfDescriptors.RecordingTime); // AccessTime + UdfDescriptors.WriteTimestamp(buf, 84, UdfDescriptors.RecordingTime); // ModificationTime + UdfDescriptors.WriteTimestamp(buf, 96, UdfDescriptors.RecordingTime); // AttributeTime + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(108), 1); // Checkpoint + UdfDescriptors.WriteEntityId(buf, 128, UdfDescriptors.ImplementationId, + UdfDescriptors.ImplementationSuffix); BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(168), 0); // L_EA - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(172), 8); // L_AD = 8 (one short_ad) - var allocLen = Math.Max(fileSize, SectorSize); - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(176), (uint)allocLen); - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(180), (uint)dataLbn); - FinalizeTag(buf, 0, (176 - 16) + 0 + 8); // body covers FE header + L_EA + L_AD + // A zero-length file records no extent at all. + var lAd = fileSize > 0 ? 8 : 0; + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(172), (uint)lAd); + if (lAd > 0) { + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(176), (uint)fileSize); + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(180), (uint)dataLbn); + } + FinalizeTag(buf, 0, (176 - 16) + 0 + lAd); // body covers FE header + L_EA + L_AD return buf; } // ── FID construction ────────────────────────────────────────────────────── + /// + /// Builds a File Identifier Descriptor. Its tag location is stamped by + /// once the caller knows which block the + /// record will start in. + /// private static byte[] BuildFid(byte flags, int icbLbn, string name) { - var nameBytes = name.Length == 0 ? [] : EncodeCs0(name); + var nameBytes = OstaCompressedUnicode.Encode(name); var fidLen = 38 + nameBytes.Length; var padded = (fidLen + 3) & ~3; var buf = new byte[padded]; - BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), FidTagId); - BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(2), 2); // descriptor version + WriteTag(buf, 0, FidTagId, 0); + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(16), 1); // FileVersionNumber buf[18] = flags; buf[19] = (byte)nameBytes.Length; BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(20), (uint)SectorSize); // ICB ext length @@ -545,12 +609,13 @@ private static byte[] BuildFid(byte flags, int icbLbn, string name) { return buf; } - private static byte[] EncodeCs0(string name) { - var utf8 = Encoding.UTF8.GetBytes(name); - var result = new byte[1 + utf8.Length]; - result[0] = 8; // CS0 = UTF-8 - utf8.CopyTo(result, 1); - return result; + /// + /// Records the logical block a File Identifier Descriptor starts in and + /// re-seals its tag. A record spanning two blocks names the first of them. + /// + private static void StampFidLocation(byte[] fid, int lbn) { + BinaryPrimitives.WriteUInt32LittleEndian(fid.AsSpan(12), (uint)lbn); + FinalizeTag(fid, 0, fid.Length - 16); } // ── Wipe (Remove path) ──────────────────────────────────────────────────── @@ -623,38 +688,93 @@ private static void WipeRange(Stream image, long start, long count) { private static void UpdatePartitionLength(Stream image, Context ctx, int newPartLenSectors) { if (newPartLenSectors <= ctx.PartitionLengthSectors) return; - var pd = ReadSector(image, ctx.PdSectorLba); - BinaryPrimitives.WriteUInt32LittleEndian(pd.AsSpan(192), (uint)newPartLenSectors); - FinalizeTag(pd, 0, 496); // PdBodySize - WriteSector(image, ctx.PdSectorLba, pd); + foreach (var lba in ctx.PdSectorLbas) { + var pd = ReadSector(image, lba); + if (BinaryPrimitives.ReadUInt16LittleEndian(pd) != PdTagId) continue; + BinaryPrimitives.WriteUInt32LittleEndian(pd.AsSpan(192), (uint)newPartLenSectors); + FinalizeTag(pd, 0, 496); // PdBodySize + WriteSector(image, lba, pd); + } - EnsureLength(image, (long)(ctx.PartitionStart + newPartLenSectors) * SectorSize); + // The volume keeps one block past the partition for its second anchor + // (ECMA-167 §3/8.4), which has to follow the partition as it grows. + var lastBlock = ctx.PartitionStart + newPartLenSectors; + EnsureLength(image, (long)(lastBlock + 1) * SectorSize); + MoveTailAnchor(image, lastBlock); + UpdateIntegrity(image, ctx, newPartLenSectors); } - // ── Tag helpers (mirror UdfWriter) ──────────────────────────────────────── + /// + /// Re-records the volume's second anchor in its new last block, copied from + /// the one at block 256 so both name the same descriptor sequences. + /// + private static void MoveTailAnchor(Stream image, int lastBlock) { + var anchor = ReadSector(image, AvdpLba); + if (BinaryPrimitives.ReadUInt16LittleEndian(anchor) != 2) return; + BinaryPrimitives.WriteUInt32LittleEndian(anchor.AsSpan(12), (uint)lastBlock); + FinalizeTag(anchor, 0, 496); + WriteSector(image, lastBlock, anchor); + } - private static void WriteTag(byte[] buf, int off, ushort tagId, uint tagLocation) { - BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(off), tagId); - BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(off + 2), 2); // descriptor version - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(off + 12), tagLocation); + /// + /// Refreshes the logical volume integrity descriptor after the partition + /// grew: a stale size table describes a volume smaller than its own data, + /// and every tool reads the difference as missing blocks. + /// + private static void UpdateIntegrity(Stream image, Context ctx, int newPartLenSectors) { + if (ctx.LvidLba <= 0) return; + if ((long)(ctx.LvidLba + 1) * SectorSize > image.Length) return; + + var lvid = ReadSector(image, ctx.LvidLba); + if (BinaryPrimitives.ReadUInt16LittleEndian(lvid) != 9) return; + + var partitions = BinaryPrimitives.ReadUInt32LittleEndian(lvid.AsSpan(72)); + if (partitions < 1) return; + var implementationUse = (int)BinaryPrimitives.ReadUInt32LittleEndian(lvid.AsSpan(76)); + var tables = 80 + (int)partitions * 4; + + BinaryPrimitives.WriteUInt32LittleEndian(lvid.AsSpan(80), 0); // free space + BinaryPrimitives.WriteUInt32LittleEndian(lvid.AsSpan(tables), (uint)newPartLenSectors); // size + FinalizeTag(lvid, 0, (tables + (int)partitions * 4 - 16) + implementationUse); + WriteSector(image, ctx.LvidLba, lvid); } - private static void FinalizeTag(byte[] buf, int tagOffset, int bodyLength) { - var bodyStart = tagOffset + 16; - if (bodyStart + bodyLength > buf.Length) bodyLength = buf.Length - bodyStart; - if (bodyLength < 0) bodyLength = 0; - var crc = Crc16Ccitt.Compute(buf.AsSpan(bodyStart, bodyLength)); - BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(tagOffset + 8), crc); - BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(tagOffset + 10), (ushort)bodyLength); - buf[tagOffset + 4] = 0; - byte sum = 0; - for (var i = 0; i < 16; i++) { - if (i == 4) continue; - sum = (byte)(sum + buf[tagOffset + i]); + /// Adjusts the file count the integrity descriptor advertises. + private static void AdjustFileCount(Stream image, Context ctx, int delta) { + if (ctx.LvidLba <= 0 || delta == 0) return; + if ((long)(ctx.LvidLba + 1) * SectorSize > image.Length) return; + + var lvid = ReadSector(image, ctx.LvidLba); + if (BinaryPrimitives.ReadUInt16LittleEndian(lvid) != 9) return; + + var partitions = (int)BinaryPrimitives.ReadUInt32LittleEndian(lvid.AsSpan(72)); + if (partitions < 1) return; + var implementationUse = (int)BinaryPrimitives.ReadUInt32LittleEndian(lvid.AsSpan(76)); + // OSTA UDF §2.2.6.4 puts the counts after the entity identifier that opens + // the implementation use area. + var counts = 80 + partitions * 8 + 32; + if (implementationUse < 40 || counts + 8 > SectorSize) return; + + var files = (int)BinaryPrimitives.ReadUInt32LittleEndian(lvid.AsSpan(counts)); + BinaryPrimitives.WriteUInt32LittleEndian(lvid.AsSpan(counts), (uint)Math.Max(0, files + delta)); + // A new object also consumes the next unique identifier. + if (delta > 0) { + var next = BinaryPrimitives.ReadUInt64LittleEndian(lvid.AsSpan(40)); + BinaryPrimitives.WriteUInt64LittleEndian(lvid.AsSpan(40), next + (ulong)delta); } - buf[tagOffset + 4] = sum; + + FinalizeTag(lvid, 0, (80 + partitions * 8 - 16) + implementationUse); + WriteSector(image, ctx.LvidLba, lvid); } + // ── Tag helpers ─────────────────────────────────────────────────────────── + + private static void WriteTag(byte[] buf, int off, ushort tagId, uint tagLocation) + => UdfDescriptors.WriteTag(buf, off, tagId, tagLocation); + + private static void FinalizeTag(byte[] buf, int tagOffset, int bodyLength) + => UdfDescriptors.FinalizeTag(buf, tagOffset, bodyLength); + // ── Stream helpers ──────────────────────────────────────────────────────── private static byte[] ReadSector(Stream image, int lba) { diff --git a/FileSystems/FileSystem.Udf/UdfReader.cs b/FileSystems/FileSystem.Udf/UdfReader.cs index 3b448fd58..b1319c1f4 100644 --- a/FileSystems/FileSystem.Udf/UdfReader.cs +++ b/FileSystems/FileSystem.Udf/UdfReader.cs @@ -58,12 +58,102 @@ private ulong U64(long off) ? BinaryPrimitives.ReadUInt64LittleEndian(_img.Read(off, 8)) : 0ul; + /// + /// Logical block sizes ECMA-167 volumes are recorded with, most common first. + /// The Anchor Volume Descriptor Pointer is the only descriptor at a fixed + /// address (logical block 256), and that address is counted in logical blocks + /// — so until the anchor is found the block size is unknown and has to be + /// probed, exactly as udftools does. + /// + private static readonly int[] CandidateBlockSizes = [2048, 512, 1024, 4096, 8192, 16384, 32768]; + + /// + /// True when a descriptor tag sits at with the + /// given identifier and records as its + /// own address. The ECMA-167 §7.2 TagChecksum is verified too, so a run of + /// file data that happens to start with the anchor's tag identifier cannot be + /// mistaken for a descriptor. + /// + private bool IsTagAt(long offset, ushort identifier, uint expectedLocation) { + if (offset < 0 || offset + 16 > _len) + return false; + + var tag = _img.Read(offset, 16); + if (BinaryPrimitives.ReadUInt16LittleEndian(tag) != identifier) + return false; + if (BinaryPrimitives.ReadUInt32LittleEndian(tag.AsSpan(12)) != expectedLocation) + return false; + + byte sum = 0; + for (var i = 0; i < 16; ++i) + if (i != 4) + sum = (byte)(sum + tag[i]); + + return sum == tag[4]; + } + + /// + /// Locates the Anchor Volume Descriptor Pointer and, with it, the volume's + /// logical block size. ECMA-167 §3/8.4 puts an anchor at logical block 256 and + /// at the last block of the volume (and optionally 256 blocks before it); each + /// is tried for every plausible block size. + /// + private long FindAnchor() { + foreach (var blockSize in CandidateBlockSizes) { + var totalBlocks = _len / blockSize; + if (totalBlocks <= 256) + continue; + + foreach (var block in new[] { 256L, totalBlocks - 1, totalBlocks - 257 }) { + if (block < 256) + continue; + var offset = block * blockSize; + if (!this.IsTagAt(offset, 2, (uint)block)) + continue; + // The sequence the candidate names has to describe a volume of the same + // block size, so a run of file data that survives the tag checks cannot + // carry the read off to the wrong addresses in silence. + if (!this.SequenceDeclaresBlockSize(U32(offset + 20), U32(offset + 16), blockSize)) + continue; + + this._blockSize = blockSize; + return offset; + } + } + + throw new InvalidDataException("UDF: no Anchor Volume Descriptor Pointer found."); + } + + /// + /// True when the volume descriptor sequence at + /// holds a Logical Volume Descriptor declaring . + /// + private bool SequenceDeclaresBlockSize(uint location, uint byteLength, int blockSize) { + var descriptors = Math.Min(byteLength / (uint)blockSize, 64); + for (var i = 0u; i < descriptors; ++i) { + var offset = ((long)location + i) * blockSize; + if (offset < 0 || offset + 512 > _len) + return false; + + var tag = U16(offset); + if (tag == 6) + return U32(offset + 212) == (uint)blockSize; + if (tag == 8) + return false; + } + + return false; + } + private void Parse() { - if (_len < 257L * SectorSize) + if (_len < 257L * 512) throw new InvalidDataException("UDF: image too small."); + // ECMA-167 §2/9.1: the Volume Recognition Sequence starts at byte 32768 and + // occupies consecutive logical sectors, whose size is 2048 or the block size + // when that is larger. Scanning at the 2048 stride covers both. var foundNsr = false; - for (var sector = 16L; sector < 20 && sector * SectorSize + 6 < _len; ++sector) { + for (var sector = 16L; sector < 24 && sector * SectorSize + 6 < _len; ++sector) { var off = sector * SectorSize; var id = Encoding.ASCII.GetString(_img.Read(off + 1, 5)); if (id is "NSR02" or "NSR03") { @@ -74,18 +164,16 @@ private void Parse() { if (!foundNsr) throw new InvalidDataException("UDF: no NSR02/NSR03 descriptor found."); - var avdpOff = 256L * SectorSize; - if (U16(avdpOff) != 2) - throw new InvalidDataException("UDF: invalid AVDP tag."); + var avdpOff = this.FindAnchor(); var mainVdsLoc = U32(avdpOff + 20); var mainVdsLen = U32(avdpOff + 16); long partStart = 0; long fsdLbn = 0; - var vdsSectors = (int)(mainVdsLen / SectorSize); + var vdsSectors = (int)(mainVdsLen / (uint)_blockSize); for (var i = 0; i < vdsSectors && i < 64; ++i) { - var off = ((long)mainVdsLoc + i) * SectorSize; + var off = ((long)mainVdsLoc + i) * _blockSize; if (off + 512 > _len) break; @@ -93,9 +181,9 @@ private void Parse() { if (tagId == 5) { partStart = U32(off + 188); } else if (tagId == 6) { - _blockSize = checked((int)U32(off + 212)); - if (_blockSize == 0) - _blockSize = SectorSize; + var declared = checked((int)U32(off + 212)); + if (declared > 0) + _blockSize = declared; fsdLbn = U32(off + 252); } else if (tagId == 8) { break; @@ -115,8 +203,11 @@ private void Parse() { ReadDirectory(rootIcbLbn, checked((int)rootIcbLen), ""); } + // The Partition Starting Location (ECMA-167 §3/10.5.9) is counted in logical + // blocks, not in 2048-byte sectors: scaling it by a fixed 2048 addressed the + // wrong place on every volume whose block size is not 2048. private long PartitionOffset(long lbn) - => checked(_partitionStart * SectorSize + lbn * (long)_blockSize); + => checked((_partitionStart + lbn) * (long)_blockSize); private void ReadDirectory(long icbLbn, int icbLen, string basePath) { var feOffset = PartitionOffset(icbLbn); @@ -182,14 +273,7 @@ private void ReadDirectory(long icbLbn, int icbLen, string basePath) { if (nameStart > dirData.Length - fidIdLen) break; - string name; - if (fidIdLen > 1 && dirData[nameStart] == 8) - name = Encoding.UTF8.GetString(dirData, nameStart + 1, fidIdLen - 1); - else if (fidIdLen > 1 && dirData[nameStart] == 16) - name = Encoding.BigEndianUnicode.GetString(dirData, nameStart + 1, fidIdLen - 1); - else - name = Encoding.ASCII.GetString(dirData, nameStart, fidIdLen); - name = name.TrimEnd('\0'); + var name = OstaCompressedUnicode.Decode(dirData.AsSpan(nameStart, fidIdLen)); var fullPath = string.IsNullOrEmpty(basePath) ? name : $"{basePath}/{name}"; if (isDir) { @@ -227,6 +311,79 @@ private long GetFileSize(long icbLbn) { return (long)size; } + /// One decoded allocation descriptor. + private readonly record struct AllocationDescriptor(int ExtentType, long Length, uint Block, ushort Partition); + + /// + /// Walks an allocation descriptor list, following the continuations that + /// ECMA-167 §4/14.14.1.1 records as extent type 3. Once a File Entry's own + /// descriptor area is full the rest of the list lives in an Allocation Extent + /// Descriptor (tag 258) in a block of its own, and a reader that stops at the + /// type-3 entry sees only as much of the object as fitted in the entry — for + /// a directory that means most of its children vanish. + /// + private IEnumerable EnumerateAllocationDescriptors(long adStart, int lAd, int adType) { + if (adType is not (0 or 1)) + yield break; + + var stride = adType == 0 ? 8 : 16; + var visited = new HashSet(); + var pos = adStart; + var end = adStart + lAd; + + while (true) { + if (pos < 0 || end > _len || end < pos) + yield break; + + long? continuation = null; + while (pos + stride <= end) { + var raw = U32(pos); + var extentType = (int)(raw >> ExtentTypeShift); + var length = (long)(raw & ExtentLengthMask); + var block = U32(pos + 4); + var partition = adType == 1 ? U16(pos + 8) : (ushort)0; + pos += stride; + + if (length == 0) + continue; + + if (extentType == 3) { + // The continuation replaces the rest of this list; anything after it + // in the current block is not part of the object. + continuation = block; + break; + } + + yield return new(extentType, length, block, partition); + } + + if (continuation is not { } nextBlock) + yield break; + + long nextOffset; + try { + nextOffset = PartitionOffset(nextBlock); + } catch (OverflowException) { + yield break; + } + + // A continuation pointing back at a block already walked would loop for + // ever; refusing to revisit one bounds the walk. + if (!visited.Add(nextOffset)) + yield break; + if (nextOffset < 0 || nextOffset + 24 > _len) + yield break; + // ECMA-167 §4/14.5: the continuation block opens with an Allocation + // Extent Descriptor whose header says how many bytes of descriptors follow. + if (U16(nextOffset) != 258) + yield break; + + var nextLength = U32(nextOffset + 20); + pos = nextOffset + 24; + end = pos + nextLength; + } + } + private UdfFileDataLayout GetFileDataLayout(long icbLbn, long informationLength) { if (informationLength < 0) return new([], "UDF file has a negative logical length."); @@ -272,35 +429,21 @@ private UdfFileDataLayout GetFileDataLayout(long icbLbn, long informationLength) if (adType is not (0 or 1)) return new([], $"UDF allocation descriptor type {adType} is not yet supported for mounted reads."); - var stride = adType == 0 ? 8 : 16; var segments = new List(); long logicalOffset = 0; - var pos = adStart; - - while (pos + stride <= adEnd && logicalOffset < informationLength) { - var rawLength = U32(pos); - var extentType = (int)(rawLength >> ExtentTypeShift); - var extentLength = (long)(rawLength & ExtentLengthMask); - var extentLbn = U32(pos + 4); - if (extentLength == 0) { - pos += stride; - continue; - } - if (extentType == 3) - return new(segments, "UDF continuation allocation descriptors are not yet supported for mounted reads."); + foreach (var ad in this.EnumerateAllocationDescriptors(adStart, lAd, adType)) { + if (logicalOffset >= informationLength) + break; - if (adType == 1) { - var partitionReference = U16(pos + 8); - if (partitionReference != 0) - return new(segments, $"UDF long allocation descriptor references partition map {partitionReference}; only the decoded primary partition is supported."); - } + if (ad.Partition != 0) + return new(segments, $"UDF long allocation descriptor references partition map {ad.Partition}; only the decoded primary partition is supported."); - var logicalLength = Math.Min(extentLength, informationLength - logicalOffset); - if (extentType == 0) { + var logicalLength = Math.Min(ad.Length, informationLength - logicalOffset); + if (ad.ExtentType == 0) { long physicalOffset; try { - physicalOffset = PartitionOffset(extentLbn); + physicalOffset = PartitionOffset(ad.Block); } catch (OverflowException) { return new(segments, "UDF file extent address overflows the image address space."); } @@ -314,7 +457,6 @@ private UdfFileDataLayout GetFileDataLayout(long icbLbn, long informationLength) } logicalOffset += logicalLength; - pos += stride; } if (logicalOffset != informationLength) @@ -337,45 +479,33 @@ private UdfFileDataLayout GetFileDataLayout(long icbLbn, long informationLength) return null; using var ms = new MemoryStream(checked((int)infoLength)); - var pos = adStart; - long end; - try { - end = checked(adStart + lAd); - } catch (OverflowException) { - return null; - } - if (adStart < 0 || end > _len) + if (adStart < 0 || adStart + lAd > _len) return null; - var stride = adType == 0 ? 8 : 16; var zeroBuffer = new byte[8192]; - while (pos + stride <= end && ms.Length < infoLength) { - var rawLength = U32(pos); - var extentType = (int)(rawLength >> ExtentTypeShift); - var extentLength = (long)(rawLength & ExtentLengthMask); - var extentLbn = U32(pos + 4); - if (extentType == 3) - return null; - if (adType == 1 && U16(pos + 8) != 0) + foreach (var ad in this.EnumerateAllocationDescriptors(adStart, lAd, adType)) { + if (ms.Length >= infoLength) + break; + if (ad.Partition != 0) return null; - var take = Math.Min(extentLength, infoLength - ms.Length); - if (take > 0) { - if (extentType == 0) { - var physical = PartitionOffset(extentLbn); - if (physical < 0 || physical > _len - take) - return null; - _img.CopyTo(physical, ms, take); - } else { - var remaining = take; - while (remaining > 0) { - var chunk = checked((int)Math.Min(zeroBuffer.Length, remaining)); - ms.Write(zeroBuffer, 0, chunk); - remaining -= chunk; - } + var take = Math.Min(ad.Length, infoLength - ms.Length); + if (take <= 0) + continue; + + if (ad.ExtentType == 0) { + var physical = PartitionOffset(ad.Block); + if (physical < 0 || physical > _len - take) + return null; + _img.CopyTo(physical, ms, take); + } else { + var remaining = take; + while (remaining > 0) { + var chunk = checked((int)Math.Min(zeroBuffer.Length, remaining)); + ms.Write(zeroBuffer, 0, chunk); + remaining -= chunk; } } - pos += stride; } return ms.Length == infoLength ? ms.ToArray() : null; diff --git a/FileSystems/FileSystem.Udf/UdfWriter.cs b/FileSystems/FileSystem.Udf/UdfWriter.cs index 0d7fe6c96..348f3aff5 100644 --- a/FileSystems/FileSystem.Udf/UdfWriter.cs +++ b/FileSystems/FileSystem.Udf/UdfWriter.cs @@ -1,60 +1,97 @@ #pragma warning disable CS1591 using System.Buffers.Binary; -using System.Text; -using Compression.Core.Checksums; +using static FileSystem.Udf.UdfDescriptors; namespace FileSystem.Udf; /// -/// Writes a minimal UDF 1.02 filesystem image (ECMA-167). Builds a real -/// directory tree from slash-separated file paths, short allocation -/// descriptors. Computes ECMA-167 §7.2.1 DescriptorCRC -/// (CRC-16/CCITT, init=0, poly=0x1021, non-reflected) and TagChecksum for -/// every descriptor tag so that strict readers (xorriso, Linux udf.ko, -/// mkudffs fsck) accept the produced images. +/// Writes a UDF 2.01 volume image (ECMA-167 plus the OSTA UDF profile). Builds a +/// real directory tree from slash-separated file paths using short allocation +/// descriptors, and records every descriptor the standard's own tools look for: +/// both volume descriptor sequences, both anchors, the unallocated space and +/// implementation use descriptors, and a closed logical volume integrity +/// descriptor. /// -/// Layout: +/// Layout (2048-byte logical blocks): /// -/// Sectors 0-15: System area -/// Sector 16: VRS BEA01 -/// Sector 17: VRS NSR02 -/// Sector 18: VRS TEA01 -/// Sector 32-35: Main VDS (PVD + Partition + LVD + Terminator) -/// Sector 256: AVDP -/// Sector 257: Partition start: File Set Descriptor (FSD) at LBN 0 -/// Sector 258: Root directory File Entry at LBN 1 -/// Sector 259+: Per-node File Entries, directory FID data, file data +/// Block 0-15: System area +/// Block 16: VRS BEA01 +/// Block 17: VRS NSR03 +/// Block 18: VRS TEA01 +/// Block 32-47: Main volume descriptor sequence +/// Block 48-63: Reserve volume descriptor sequence +/// Block 64-65: Logical volume integrity sequence (LVID + terminator) +/// Block 256: Anchor volume descriptor pointer +/// Block 257: Partition start; File Set Descriptor at LBN 0 +/// Block 258: Root directory File Entry at LBN 1 +/// Block 259+: Per-node File Entries, directory FID data, file data +/// Last block: Second anchor volume descriptor pointer /// /// -/// A directory's data is a sequence of File Identifier Descriptors (FID, -/// tag 257). The first FID of every directory is the parent entry (Parent -/// flag 0x08, zero-length identifier, ICB pointing at the parent FE). Every -/// directory and file is a File Entry (FE, tag 261); directories carry file -/// type 4, regular files file type 5. A subdirectory FID carries the -/// Directory flag 0x02 and points at the child directory's FE. +/// A directory's data is a dense sequence of File Identifier Descriptors (FID, +/// tag 257) — dense because ECMA-167 lets a FID span a logical block boundary +/// and Linux's udf driver reads directory bytes as one uninterrupted run, so +/// padding a FID onto the next block makes the directory unreadable. The first +/// FID of every directory is the parent entry (Parent flag 0x08, zero-length +/// identifier). Every directory and file is a File Entry (FE, tag 261); +/// directories carry file type 4, regular files file type 5. /// public sealed class UdfWriter { private const int Sector = 2048; + + private const int VrsFirstSector = 16; + private const int MainVdsSector = 32; + private const int VdsSectors = 16; + private const int ReserveVdsSector = MainVdsSector + VdsSectors; + private const int LvidSector = ReserveVdsSector + VdsSectors; + private const int LvidSectors = 2; + private const int AnchorSector = 256; private const int PartitionStartSector = 257; - // Descriptor body sizes per ECMA-167 §10. The body starts at offset 16 - // (after the 16-byte descriptor tag) and DescriptorCRCLength covers - // exactly these many bytes. Using fixed structure sizes (rather than - // the full sector) keeps us compatible with real UDF implementations. - private const int PvdBodySize = 496; // AVDP/PVD/PD sector size 512 - 16 tag - private const int AvdpBodySize = 496; - private const int PdBodySize = 496; - private const int LvdBodySize = 440 - 16; // 440 header + zero partition maps - private const int TerminatorBodySize = 496; - private const int FsdBodySize = 496; - private const int FeBodyHeader = 160; // 176 - 16 (up to L_EA), plus L_EA + L_AD content + /// File Set Descriptor block, relative to the partition start. + private const int FsdLbn = 0; + + /// Root directory File Entry block, relative to the partition start. + private const int RootFeLbn = 1; + + // Descriptor body sizes per ECMA-167 §3/10. The body starts 16 bytes after the + // tag and DescriptorCRCLength covers exactly these many bytes. + private const int VolumeDescriptorBodySize = 496; // 512-byte descriptor minus its tag + private const int LvdBodySize = 446 - 16; // through the single Type-1 partition map + private const int UsdBodySize = 24 - 16; // no allocation descriptors follow + private const int LvidBodySize = (88 - 16) + LvidImplementationUseSize; + private const int LvidImplementationUseSize = 46; + private const int FeHeaderBodySize = 176 - 16; // File Entry header, up to L_EA + + /// + /// Bytes one short allocation descriptor may address. ECMA-167 caps an extent + /// length at 2^30-1 and OSTA UDF §2.3.10.1 requires every extent but the last + /// of a file to be a whole number of blocks, so the usable maximum is the + /// largest block multiple below 2^30. + /// + private const long MaxExtentBytes = (1L << 30) - Sector; + + /// + /// Short allocation descriptors that fit in one File Entry. They start after + /// the 176-byte header and the extended attributes, of which this writer + /// records none. + /// + private const int MaxAllocationDescriptors = (Sector - 176) / 8; + + /// Largest file this writer can address without descriptor continuation. + internal const long MaxFileBytes = MaxAllocationDescriptors * MaxExtentBytes; + + /// + /// First unique identifier handed to a file or directory. OSTA UDF §3.2.1 + /// reserves 0 for the root directory and 1..15 for the standard's own use. + /// + private const uint FirstUniqueId = 16; private readonly List<(string name, byte[] data)> _files = []; /// /// ECMA-167 PVD Volume Identifier (dstring at PVD offset 24, 32 bytes). /// Linux's udf driver surfaces this as the volume label. Default "UDF Volume". - /// Truncated to 31 bytes (ECMA-167 dstring length byte caps at 31). /// public string VolumeIdentifier { get; set; } = "UDF Volume"; @@ -117,7 +154,8 @@ private sealed class Node { public int FeLbn; // File Entry block public int DataLbn; // first data block (FID data for dirs, payload for files) public int DataSectors; // sectors occupied by data - public int DataLength; // exact byte length of the (directory or file) data + public long DataLength; // exact byte length of the (directory or file) data + public uint UniqueId; // OSTA UDF §3.2.1 unique identifier } /// @@ -126,46 +164,47 @@ private sealed class Node { public void WriteTo(Stream output) { var root = BuildTree(); - // LBN 0 is the FSD; the root File Entry lives at LBN 1. Everything else - // (per-node FEs, directory FID data, file payloads) is laid out after. - root.FeLbn = 1; - var nextLbn = 2; - AssignLayout(root, ref nextLbn); + root.FeLbn = RootFeLbn; + root.UniqueId = 0; + var nextLbn = RootFeLbn + 1; + var nextUniqueId = FirstUniqueId; + AssignLayout(root, ref nextLbn, ref nextUniqueId); var totalPartitionSectors = nextLbn; // LBNs 0..nextLbn-1 are all in use - var totalImageSectors = PartitionStartSector + totalPartitionSectors; + var lastBlock = PartitionStartSector + totalPartitionSectors; - // ── Write system area (sectors 0-15) ── - WritePadding(output, 16); + var (fileCount, directoryCount) = Count(root); - // ── Write VRS (sectors 16-18) ── + // ── System area (blocks 0-15) ── + WritePadding(output, VrsFirstSector); + + // ── Volume recognition sequence (blocks 16-18) ── WriteVrs(output, "BEA01"); - WriteVrs(output, "NSR02"); + WriteVrs(output, "NSR03"); WriteVrs(output, "TEA01"); - // ── Padding to sector 32 ── - WritePadding(output, 32 - 19); + WritePadding(output, MainVdsSector - (VrsFirstSector + 3)); + + // ── Both volume descriptor sequences ── + this.WriteVds(output, MainVdsSector, totalPartitionSectors); + this.WriteVds(output, ReserveVdsSector, totalPartitionSectors); - // ── Main VDS at sectors 32-35 ── - this.WritePvd(output, 32, totalImageSectors); - WritePartitionDescriptor(output, 33, PartitionStartSector, totalPartitionSectors); - WriteLvd(output, 34); - WriteTerminator(output, 35); + // ── Logical volume integrity sequence ── + this.WriteLvid(output, LvidSector, totalPartitionSectors, fileCount, directoryCount, nextUniqueId); + WriteTerminator(output, LvidSector + 1); - // ── Padding to sector 256 ── - WritePadding(output, 256 - 36); + WritePadding(output, AnchorSector - (LvidSector + LvidSectors)); - // ── AVDP at sector 256 ── - WriteAvdp(output, 256, mainVdsLoc: 32, mainVdsLen: 4 * Sector); + // ── First anchor ── + WriteAnchor(output, AnchorSector); - // ── Partition data (starting at sector 257 = LBN 0) ── - WriteFsd(output, lbn: 0, rootIcbLbn: root.FeLbn); + // ── Partition data (starting at block 257 = LBN 0) ── + this.WriteFsd(output, FsdLbn, RootFeLbn); // Emit blocks in LBN order so the stream stays sequential. Gather every // block-producing action keyed by its starting LBN, then drain in order. - blocksOutput = output; var blocks = new SortedDictionary(); - CollectBlocks(root, blocks); + CollectBlocks(root, blocks, output); var written = 1; // LBN 0 (FSD) already written foreach (var (lbn, emit) in blocks) { @@ -180,6 +219,11 @@ public void WriteTo(Stream output) { if (written != totalPartitionSectors) throw new InvalidOperationException( $"UDF layout mismatch: wrote {written} partition sectors, expected {totalPartitionSectors}."); + + // ── Second anchor, in the volume's last block ── + // ECMA-167 §3/8.4 wants an anchor at block 256 and at the last block of the + // volume; a volume carrying only the first is one udfinfo reports on. + WriteAnchor(output, lastBlock); } /// @@ -233,17 +277,34 @@ private Node BuildTree() { return root; } + /// Files and directories below (and including) . + private static (int files, int directories) Count(Node node) { + if (!node.IsDirectory) return (1, 0); + var files = 0; + var directories = 1; + foreach (var child in node.Children) { + var (f, d) = Count(child); + files += f; + directories += d; + } + + return (files, directories); + } + /// /// Assigns File Entry and data block numbers depth-first. The node's own FE /// LBN must already be set by the caller; this method assigns FE LBNs for /// all children first (so a directory's FID data can reference them), then /// the directory's own FID-data block(s), then recurses. /// - private void AssignLayout(Node node, ref int nextLbn) { + private static void AssignLayout(Node node, ref int nextLbn, ref uint nextUniqueId) { if (node.IsDirectory) { - // Reserve FE blocks for every child up front. - foreach (var child in node.Children) + // Reserve FE blocks and identifiers for every child up front: the parent's + // FIDs name both. + foreach (var child in node.Children) { child.FeLbn = nextLbn++; + child.UniqueId = nextUniqueId++; + } // Reserve this directory's FID-data block(s). var fidData = BuildFidData(node); @@ -252,13 +313,26 @@ private void AssignLayout(Node node, ref int nextLbn) { node.DataLbn = nextLbn; nextLbn += node.DataSectors; + if (node.DataSectors > MaxAllocationDescriptors) + throw new InvalidOperationException( + $"UDF directory '{node.Name}' needs {node.DataSectors} data blocks but only " + + $"{MaxAllocationDescriptors} short allocation descriptors fit in one File Entry; " + + "allocation descriptor continuation is not supported."); + // Recurse into children so their own subtree blocks are laid out. foreach (var child in node.Children) - AssignLayout(child, ref nextLbn); + AssignLayout(child, ref nextLbn, ref nextUniqueId); } else { - var len = node.EffectiveLength; - node.DataLength = (int)len; - node.DataSectors = Math.Max(1, (int)((len + Sector - 1) / Sector)); + var length = node.EffectiveLength; + if (length > MaxFileBytes) + throw new InvalidOperationException( + $"UDF file '{node.Name}' is {length:N0} bytes; this writer addresses at most " + + $"{MaxFileBytes:N0} without allocation descriptor continuation."); + + node.DataLength = length; + // An empty file gets no extent at all: an allocation descriptor naming a + // block a zero-length file does not own is a chain longer than the size. + node.DataSectors = (int)((length + Sector - 1) / Sector); node.DataLbn = nextLbn; nextLbn += node.DataSectors; } @@ -269,45 +343,42 @@ private void AssignLayout(Node node, ref int nextLbn) { /// the starting LBN of each block group, so the writer can drain them in /// strictly ascending order. /// - private void CollectBlocks(Node node, SortedDictionary blocks) { + private static void CollectBlocks(Node node, SortedDictionary blocks, Stream output) { if (node.IsDirectory) { - // This directory's File Entry. var dirNode = node; - blocks[node.FeLbn] = () => WriteDirectoryFe(blocksOutput, dirNode); + blocks[node.FeLbn] = () => WriteDirectoryFe(output, dirNode); - // This directory's FID data. blocks[node.DataLbn] = () => { var fidData = BuildFidData(dirNode); - blocksOutput.Write(fidData); + output.Write(fidData); var pad = dirNode.DataSectors * Sector - fidData.Length; - if (pad > 0) blocksOutput.Write(new byte[pad]); + if (pad > 0) output.Write(new byte[pad]); }; foreach (var child in node.Children) - CollectBlocks(child, blocks); + CollectBlocks(child, blocks, output); } else { var fileNode = node; - blocks[node.FeLbn] = () => WriteFileFe(blocksOutput, fileNode.FeLbn, fileNode.DataLength, fileNode.DataLbn); + blocks[node.FeLbn] = () => WriteFileFe(output, fileNode); + if (fileNode.DataSectors == 0) + return; + blocks[node.DataLbn] = () => { long produced; if (fileNode.StreamOpener != null) { // Stream the body straight into the sequential output in 64 KiB chunks // — never buffered as a byte[]. Exactly DataLength bytes are copied. - produced = StreamCopy(blocksOutput, fileNode.StreamOpener, fileNode.DataLength); + produced = StreamCopy(output, fileNode.StreamOpener, fileNode.DataLength); } else { - blocksOutput.Write(fileNode.Data); + output.Write(fileNode.Data); produced = fileNode.Data.Length; } var pad = (long)fileNode.DataSectors * Sector - produced; - if (pad > 0) blocksOutput.Write(new byte[pad]); + if (pad > 0) output.Write(new byte[pad]); }; } } - // The output stream is captured for the duration of WriteTo so the block - // actions stay simple closures; set before CollectBlocks runs. - private Stream blocksOutput = Stream.Null; - /// /// Copies up to bytes from a freshly opened source /// stream to in 64 KiB chunks and returns the number of @@ -335,103 +406,56 @@ private static long StreamCopy(Stream dst, Func opener, long size) { /// identifier, parent + directory flags) pointing at the parent's FE, /// followed by one FID per child referencing the child's FE. /// - /// ECMA-167 §4/14.4: a File Identifier Descriptor may not cross a logical - /// block boundary. When the next FID would straddle the current block, the - /// remainder of that block is zero-padded and the FID starts at the next - /// block. The returned buffer is therefore a multiple of the block size, so - /// the directory spans whole blocks regardless of entry count. + /// The records are written back to back. ECMA-167 §4/14.4 permits a File + /// Identifier Descriptor to span a logical block boundary, and both mkudffs + /// and Linux's udf driver rely on that: a directory padded onto block + /// boundaries makes the driver stop at the first pad byte with "entry at + /// pos N with incorrect tag 0". /// /// private static byte[] BuildFidData(Node dir) { using var ms = new MemoryStream(); - // Parent FID (flags=0x0A: parent + directory). The parent of the root is + // Parent FID (flags 0x0A: parent + directory). The parent of the root is // itself. - var parentFeLbn = (dir.Parent ?? dir).FeLbn; - WriteFidBlockAligned(ms, 0x0A, parentFeLbn, ""); + var parent = dir.Parent ?? dir; + WriteFid(ms, dir, 0x0A, parent.FeLbn, parent.UniqueId, ""); foreach (var child in dir.Children) { var flags = child.IsDirectory ? (byte)0x02 : (byte)0x00; - WriteFidBlockAligned(ms, flags, child.FeLbn, child.Name); + WriteFid(ms, dir, flags, child.FeLbn, child.UniqueId, child.Name); } return ms.ToArray(); } /// - /// Writes one FID, first padding to the next logical block boundary if the - /// FID would otherwise cross it (ECMA-167 §14.4 forbids that crossing). + /// Writes one File Identifier Descriptor into a directory's byte stream. The + /// tag records the logical block the record starts in, which for a record + /// spanning two blocks is the first of them. /// - private static void WriteFidBlockAligned(MemoryStream ms, byte flags, int icbLbn, string name) { - var fidLen = FidLength(name); - var posInBlock = (int)(ms.Length % Sector); - if (posInBlock + fidLen > Sector) { - var pad = Sector - posInBlock; - ms.Write(new byte[pad]); - } - WriteFid(ms, flags, icbLbn, name); - } - - /// Padded on-disk byte length of a FID for the given identifier. - private static int FidLength(string name) { - var nameLen = name.Length == 0 ? 0 : EncodeCs0(name).Length; - return (38 + nameLen + 3) & ~3; - } - - private static void WriteFid(Stream s, byte flags, int icbLbn, string name) { - var nameBytes = name.Length == 0 ? [] : EncodeCs0(name); - var fidLen = 38 + nameBytes.Length; - var padded = (fidLen + 3) & ~3; + private static void WriteFid(MemoryStream ms, Node dir, byte flags, int icbLbn, uint uniqueId, string name) { + var nameBytes = OstaCompressedUnicode.Encode(name); + var padded = (38 + nameBytes.Length + 3) & ~3; var buf = new byte[padded]; - // Tag: FID = 257 - BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), 257); - BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(2), 2); // descriptor version + var startBlock = dir.DataLbn + (int)(ms.Length / Sector); + WriteTag(buf, 0, 257, (uint)startBlock); + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(16), 1); // FileVersionNumber, OSTA UDF §2.3.4.1 buf[18] = flags; - buf[19] = (byte)nameBytes.Length; // identifier length - // ICB at offset 20: long_ad (16 bytes) — length(4) + lbn(4) + partRef(2) + impl(6) - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(20), (uint)Sector); - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(24), (uint)icbLbn); - // lIU at offset 36 = 0 - // Name at offset 38 + buf[19] = (byte)nameBytes.Length; + WriteLongAd(buf, 20, Sector, (uint)icbLbn, uniqueId); + // LengthOfImplementationUse at offset 36 stays zero. nameBytes.CopyTo(buf, 38); - // ECMA-167 §14.4: FID DescriptorCRCLength covers the entire padded FID - // minus the 16-byte tag. + // ECMA-167 §4/14.4.9: the CRC covers the whole record, padding included. FinalizeTag(buf, 0, padded - 16); - s.Write(buf); - } - - private static byte[] EncodeCs0(string name) { - var utf8 = Encoding.UTF8.GetBytes(name); - var result = new byte[1 + utf8.Length]; - result[0] = 8; // CS0 compression ID = UTF-8 - utf8.CopyTo(result, 1); - return result; + ms.Write(buf); } // ── Descriptor writers ──────────────────────────────────────────────────── - // UDF domain entity-identifier suffix (UDF 1.02): UDFRevision(2 LE)=0x0102, - // DomainFlags(1)=0, Reserved(5). Stamped after the "*OSTA UDF Compliant" id. - private static readonly byte[] DomainSuffix = [0x02, 0x01, 0, 0, 0, 0, 0, 0]; - - // ECMA-167 §7.4 EntityID (regid): Flags(1) + Identifier(23) + Suffix(8). - private static void WriteRegid(byte[] buf, int off, string id, ReadOnlySpan suffix) { - buf[off] = 0; - var idb = Encoding.ASCII.GetBytes(id); - Array.Copy(idb, 0, buf, off + 1, Math.Min(idb.Length, 23)); - if (!suffix.IsEmpty) - suffix[..Math.Min(suffix.Length, 8)].CopyTo(buf.AsSpan(off + 24, 8)); - } - - // ECMA-167 §7.2.1 charspec: CharacterSetType(1)=0 (CS0) + CharacterSetInfo(63). - private static void WriteCharspec(byte[] buf, int off) { - buf[off] = 0; - Encoding.ASCII.GetBytes("OSTA Compressed Unicode").CopyTo(buf, off + 1); - } - /// Owner/group/other read+execute, in ECMA-167 permission bits. private const uint DirectoryPermissions = (1u << 12) | (1u << 10) | (1u << 7) | (1u << 5) | (1u << 2) | (1u << 0); @@ -439,42 +463,6 @@ private static void WriteCharspec(byte[] buf, int off) { /// Owner/group/other read, in ECMA-167 permission bits. private const uint FilePermissions = (1u << 12) | (1u << 7) | (1u << 2); - private static void WriteTag(byte[] buf, int off, ushort tagId, uint tagLocation) { - BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(off), tagId); - BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(off + 2), 2); // descriptor version - // DescriptorCRC (off+8..9), DescriptorCRCLength (off+10..11), TagChecksum (off+4) - // filled in by FinalizeTag after the descriptor body is populated. - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(off + 12), tagLocation); - } - - /// - /// Finalizes a UDF descriptor tag per ECMA-167 §7.2.1 by computing the - /// CRC-16/CCITT (init=0, poly=0x1021, non-reflected) over - /// bytes starting at tagOffset + 16, storing it in the tag at offsets 8..9, - /// writing the DescriptorCRCLength at offsets 10..11, and finally computing the - /// byte-sum-mod-256 TagChecksum at offset 4. - /// - private static void FinalizeTag(byte[] buf, int tagOffset, int bodyLength) { - var bodyStart = tagOffset + 16; - if (bodyStart + bodyLength > buf.Length) - bodyLength = buf.Length - bodyStart; - if (bodyLength < 0) bodyLength = 0; - - var crc = Crc16Ccitt.Compute(buf.AsSpan(bodyStart, bodyLength)); - BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(tagOffset + 8), crc); - BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(tagOffset + 10), (ushort)bodyLength); - - // TagChecksum = (sum of bytes [0..3, 5..15]) mod 256. Byte at offset 4 - // is excluded (it IS the checksum) and must be zero while computing. - buf[tagOffset + 4] = 0; - byte sum = 0; - for (var i = 0; i < 16; i++) { - if (i == 4) continue; - sum = (byte)(sum + buf[tagOffset + i]); - } - buf[tagOffset + 4] = sum; - } - private static void WritePadding(Stream output, int sectors) { for (var i = 0; i < sectors; i++) output.Write(new byte[Sector]); } @@ -482,166 +470,290 @@ private static void WritePadding(Stream output, int sectors) { private static void WriteVrs(Stream output, string id) { var buf = new byte[Sector]; buf[0] = 0; // structure type - Encoding.ASCII.GetBytes(id).CopyTo(buf, 1); + System.Text.Encoding.ASCII.GetBytes(id).CopyTo(buf, 1); buf[6] = 1; // structure version output.Write(buf); } - private static void WriteAvdp(Stream output, int sectorNum, int mainVdsLoc, int mainVdsLen) { + /// + /// ECMA-167 §3/10.2 anchor: it names both volume descriptor sequences and + /// records its own block, which is how a reader recognises it and, with it, + /// the volume's logical block size. + /// + private static void WriteAnchor(Stream output, int block) { var buf = new byte[Sector]; - WriteTag(buf, 0, 2, (uint)sectorNum); - // Main VDS extent: length(4) + location(4) at offset 16 - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(16), (uint)mainVdsLen); - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(20), (uint)mainVdsLoc); - FinalizeTag(buf, 0, AvdpBodySize); + WriteTag(buf, 0, 2, (uint)block); + WriteExtent(buf, 16, VdsSectors * Sector, MainVdsSector); + WriteExtent(buf, 24, VdsSectors * Sector, ReserveVdsSector); + FinalizeTag(buf, 0, VolumeDescriptorBodySize); output.Write(buf); } - private void WritePvd(Stream output, int sectorNum, int totalSectors) { - var buf = new byte[Sector]; - WriteTag(buf, 0, 1, (uint)sectorNum); // Primary Volume Descriptor - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(16), 1); // VDS number - // Volume Identifier — ECMA-167 dstring (max 31 ASCII bytes + length byte at offset+31). - var volId = string.IsNullOrEmpty(this.VolumeIdentifier) ? "UDF Volume" : this.VolumeIdentifier; - if (volId.Length > 31) volId = volId[..31]; - Encoding.ASCII.GetBytes(volId).CopyTo(buf, 24); - FinalizeTag(buf, 0, PvdBodySize); - output.Write(buf); + /// + /// Writes one complete volume descriptor sequence: primary volume, logical + /// volume, partition, implementation use and unallocated space descriptors, + /// then the terminator, then zero blocks out to the sequence's extent. Both + /// the main and the reserve sequence carry the same descriptors — only their + /// tag locations differ. + /// + private void WriteVds(Stream output, int firstBlock, int partitionSectors) { + this.WritePvd(output, firstBlock); + this.WriteLvd(output, firstBlock + 1); + WritePartitionDescriptor(output, firstBlock + 2, PartitionStartSector, partitionSectors); + this.WriteIuvd(output, firstBlock + 3); + WriteUsd(output, firstBlock + 4); + WriteTerminator(output, firstBlock + 5); + WritePadding(output, VdsSectors - 6); + } + + /// + /// Volume set identifier (ECMA-167 §3/10.1.10). OSTA UDF §2.2.2.5 requires + /// its first sixteen characters to be unique among volume sets, and to be + /// hexadecimal digits; deriving them from the volume identifier keeps two + /// runs over the same input byte-identical while still separating volumes + /// that are named differently. + /// + private string VolumeSetIdentifier { + get { + var hash = 0xCBF29CE484222325UL; + foreach (var c in this.EffectiveVolumeIdentifier) { + hash ^= c; + hash *= 0x100000001B3UL; + } + + return hash.ToString("x16") + this.EffectiveVolumeIdentifier; + } } - private static void WritePartitionDescriptor(Stream output, int sectorNum, int partStart, int partLen) { + private string EffectiveVolumeIdentifier + => string.IsNullOrEmpty(this.VolumeIdentifier) ? "UDF Volume" : this.VolumeIdentifier; + + private void WritePvd(Stream output, int block) { var buf = new byte[Sector]; - WriteTag(buf, 0, 5, (uint)sectorNum); - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(16), 1); // VDS number - BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(20), 1); // partition flags = allocated - BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(22), 0); // partition number 0 - WriteRegid(buf, 24, "+NSR02", default); // partition contents (ECMA-167 §4) - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(184), 1); // access type = read-only - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(188), (uint)partStart); - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(192), (uint)partLen); - FinalizeTag(buf, 0, PdBodySize); + WriteTag(buf, 0, 1, (uint)block); + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(16), 1); // VolumeDescriptorSequenceNumber + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(20), 0); // PrimaryVolumeDescriptorNumber + OstaCompressedUnicode.WriteDString(buf, 24, 32, this.EffectiveVolumeIdentifier); + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(56), 1); // VolumeSequenceNumber + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(58), 1); // MaximumVolumeSequenceNumber + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(60), 2); // InterchangeLevel + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(62), 3); // MaximumInterchangeLevel + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(64), 1); // CharacterSetList + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(68), 1); // MaximumCharacterSetList + OstaCompressedUnicode.WriteDString(buf, 72, 128, this.VolumeSetIdentifier); + WriteCharacterSet(buf, 200); // DescriptorCharacterSet + WriteCharacterSet(buf, 264); // ExplanatoryCharacterSet + WriteTimestamp(buf, 376, RecordingTime); + WriteEntityId(buf, 388, ImplementationId, ImplementationSuffix); + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(488), 1); // Flags: volume set identification + FinalizeTag(buf, 0, VolumeDescriptorBodySize); output.Write(buf); } - private void WriteLvd(Stream output, int sectorNum) { + private void WriteLvd(Stream output, int block) { var buf = new byte[Sector]; - WriteTag(buf, 0, 6, (uint)sectorNum); - WriteCharspec(buf, 20); // descriptor character set (CS0) - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(212), (uint)Sector); // logical block size - WriteRegid(buf, 216, "*OSTA UDF Compliant", DomainSuffix); // domain identifier (kernel-mandatory) - // logical_volume_contents_use @248: FSD long_ad (length=4, lbn=4, partRef=2, impl=6) - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(248), (uint)Sector); // extent length - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(252), 0); // FSD LBN = 0 - // partRef at 256 = 0 (default) - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(264), 6); // map table length (one Type-1 map) - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(268), 1); // number of partition maps - // The implementation identifier names the UDF implementation, not the product: - // an image made on Linux says so, whichever tool wrote it. - WriteRegid(buf, 272, "*Linux UDFFS", default); // implementation identifier - // Type-1 partition map @440: type(1)=1, length(1)=6, vol_seq(2)=1, part_num(2)=0 + WriteTag(buf, 0, 6, (uint)block); + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(16), 2); // VolumeDescriptorSequenceNumber + WriteCharacterSet(buf, 20); // DescriptorCharacterSet + OstaCompressedUnicode.WriteDString(buf, 84, 128, this.EffectiveVolumeIdentifier); + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(212), Sector); + WriteEntityId(buf, 216, "*OSTA UDF Compliant", DomainSuffix); + WriteLongAd(buf, 248, Sector, FsdLbn); // LogicalVolumeContentsUse: the FSD + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(264), 6); // MapTableLength + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(268), 1); // NumberOfPartitionMaps + WriteEntityId(buf, 272, ImplementationId, ImplementationSuffix); + WriteExtent(buf, 432, LvidSectors * Sector, LvidSector); // IntegritySequenceExtent + // Type-1 partition map: type(1), length(1), volume sequence(2), partition(2). buf[440] = 1; buf[441] = 6; BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(442), 1); BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(444), 0); - // CRC must cover through the partition map (offset 16..446). - FinalizeTag(buf, 0, 446 - 16); + FinalizeTag(buf, 0, LvdBodySize); + output.Write(buf); + } + + private static void WritePartitionDescriptor(Stream output, int block, int partStart, int partLen) { + var buf = new byte[Sector]; + WriteTag(buf, 0, 5, (uint)block); + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(16), 3); // VolumeDescriptorSequenceNumber + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(20), 1); // PartitionFlags: allocated + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(22), 0); // PartitionNumber + WriteEntityId(buf, 24, "+NSR03", default); // PartitionContents + // PartitionContentsUse holds the Partition Header Descriptor (OSTA UDF + // §2.2.3). All its space tables stay unrecorded, which a read-only + // partition is allowed to do since nothing will ever allocate in it. + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(184), 1); // AccessType: read-only + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(188), (uint)partStart); + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(192), (uint)partLen); + WriteEntityId(buf, 196, ImplementationId, ImplementationSuffix); + FinalizeTag(buf, 0, VolumeDescriptorBodySize); + output.Write(buf); + } + + /// + /// ECMA-167 §3/10.4 implementation use volume descriptor carrying the OSTA + /// UDF §2.2.7 "*UDF LV Info" payload: the logical volume's name and the + /// identity of whoever recorded it. + /// + private void WriteIuvd(Stream output, int block) { + var buf = new byte[Sector]; + WriteTag(buf, 0, 4, (uint)block); + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(16), 4); // VolumeDescriptorSequenceNumber + WriteEntityId(buf, 20, "*UDF LV Info", UdfEntitySuffix); + WriteCharacterSet(buf, 52); // LVICharset + OstaCompressedUnicode.WriteDString(buf, 116, 128, this.EffectiveVolumeIdentifier); + // LVInfo1..3 at 244/280/316 name the owner, organisation and contact; this + // writer knows none of them and leaves all three empty. + WriteEntityId(buf, 352, ImplementationId, ImplementationSuffix); + FinalizeTag(buf, 0, VolumeDescriptorBodySize); + output.Write(buf); + } + + /// + /// ECMA-167 §3/10.8 unallocated space descriptor. The volume this writer + /// emits has no space outside the partition to hand out, so it records no + /// allocation descriptors — but the descriptor itself has to be there. + /// + private static void WriteUsd(Stream output, int block) { + var buf = new byte[Sector]; + WriteTag(buf, 0, 7, (uint)block); + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(16), 5); // VolumeDescriptorSequenceNumber + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(20), 0); // NumberOfAllocationDescriptors + FinalizeTag(buf, 0, UsdBodySize); output.Write(buf); } - private static void WriteTerminator(Stream output, int sectorNum) { + private static void WriteTerminator(Stream output, int block) { var buf = new byte[Sector]; - WriteTag(buf, 0, 8, (uint)sectorNum); - FinalizeTag(buf, 0, TerminatorBodySize); + WriteTag(buf, 0, 8, (uint)block); + FinalizeTag(buf, 0, VolumeDescriptorBodySize); output.Write(buf); } - private static void WriteFsd(Stream output, int lbn, int rootIcbLbn) { + /// + /// ECMA-167 §3/10.10 logical volume integrity descriptor. Its integrity type + /// says whether the volume was left in a consistent state; without one, every + /// tool reports the logical volume as inconsistent and the free-space and + /// object counts as unknown. + /// + private void WriteLvid(Stream output, int block, int partitionSectors, + int fileCount, int directoryCount, uint nextUniqueId) { + var buf = new byte[Sector]; + WriteTag(buf, 0, 9, (uint)block); + WriteTimestamp(buf, 16, RecordingTime); + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(28), 1); // IntegrityType: close + // NextIntegrityExtent at 32 stays empty: this is the only integrity + // descriptor the volume has. + BinaryPrimitives.WriteUInt64LittleEndian(buf.AsSpan(40), nextUniqueId); // next UniqueID + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(72), 1); // NumberOfPartitions + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(76), LvidImplementationUseSize); + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(80), 0); // FreeSpaceTable: packed solid + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(84), (uint)partitionSectors); // SizeTable + WriteEntityId(buf, 88, ImplementationId, ImplementationSuffix); + // The root directory counts: a freshly made empty volume reports one. + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(120), (uint)fileCount); + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(124), (uint)directoryCount); + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(128), UdfRevision); // MinimumUDFReadRevision + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(130), UdfRevision); // MinimumUDFWriteRevision + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(132), UdfRevision); // MaximumUDFWriteRevision + FinalizeTag(buf, 0, LvidBodySize); + output.Write(buf); + } + + private void WriteFsd(Stream output, int lbn, int rootIcbLbn) { var buf = new byte[Sector]; WriteTag(buf, 0, 256, (uint)lbn); - BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(28), 3); // interchange level - BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(30), 3); // max interchange level - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(32), 1); // charset list - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(36), 1); // max charset list - WriteCharspec(buf, 48); // logical volume id charset (CS0) - WriteCharspec(buf, 240); // file set charset (CS0) - // Root ICB: long_ad at offset 400 - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(400), (uint)Sector); - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(404), (uint)rootIcbLbn); - WriteRegid(buf, 416, "*OSTA UDF Compliant", DomainSuffix); // domain identifier (kernel-checked) - FinalizeTag(buf, 0, FsdBodySize); + WriteTimestamp(buf, 16, RecordingTime); + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(28), 3); // InterchangeLevel + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(30), 3); // MaximumInterchangeLevel + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(32), 1); // CharacterSetList + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(36), 1); // MaximumCharacterSetList + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(40), 0); // FileSetNumber + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(44), 0); // FileSetDescriptorNumber + WriteCharacterSet(buf, 48); // LogicalVolumeIdentifierCharacterSet + OstaCompressedUnicode.WriteDString(buf, 112, 128, this.EffectiveVolumeIdentifier); + WriteCharacterSet(buf, 240); // FileSetCharacterSet + OstaCompressedUnicode.WriteDString(buf, 304, 32, this.EffectiveVolumeIdentifier); + WriteLongAd(buf, 400, Sector, (uint)rootIcbLbn); // RootDirectoryICB + WriteEntityId(buf, 416, "*OSTA UDF Compliant", DomainSuffix); + FinalizeTag(buf, 0, VolumeDescriptorBodySize); output.Write(buf); } /// - /// Writes a directory File Entry (file type 4). The file link count is set - /// to 1 (the parent's reference) plus one per subdirectory child, matching - /// ECMA-167's accounting of incoming directory links. + /// Writes the common part of an ECMA-167 §4/14.9 File Entry and returns the + /// buffer, leaving the caller to append allocation descriptors. /// - private static void WriteDirectoryFe(Stream output, Node dir) { + private static byte[] BeginFileEntry(Node node, byte fileType, uint permissions, ushort linkCount) { var buf = new byte[Sector]; - WriteTag(buf, 0, 261, (uint)dir.FeLbn); - // ICB tag at offset 16: strategy_type(2)@20 must be 4 (the only type the - // kernel supports; 0 → "unsupported strategy type"), max_entries(2)@24 = 1. - BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(20), 4); - BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(24), 1); - buf[27] = 4; // file type = directory - // Permissions at FE offset 44. Left at zero, a mounted volume gives every - // object mode 0000 and the mount cannot even be listed. r-x for owner, - // group and other (ECMA-167 4/14.9.5 bit order). - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(44), DirectoryPermissions); - // File link count is at FE offset 48 (ECMA-167 §14.9, after uid/gid/perms); - // offset 28 falls inside the ICB tag and leaves the kernel seeing link - // count 0 → "Error in udf_iget". Parent link + one per child directory. + WriteTag(buf, 0, 261, (uint)node.FeLbn); + // ICB tag (ECMA-167 §4/14.6). Strategy type 4 is the only one Linux's udf + // driver supports; type 0 makes it refuse the entry outright. + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(20), 4); // StrategyType + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(24), 1); // MaximumNumberOfEntries + buf[27] = fileType; + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(34), 0); // Flags: short allocation descriptors + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(44), permissions); + // FileLinkCount is at offset 48, after uid/gid/permissions. Offset 28 falls + // inside the ICB tag and leaves the driver seeing zero links. + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(48), linkCount); + BinaryPrimitives.WriteUInt64LittleEndian(buf.AsSpan(56), (ulong)node.DataLength); + BinaryPrimitives.WriteUInt64LittleEndian(buf.AsSpan(64), (ulong)node.DataSectors); // LogicalBlocksRecorded + WriteTimestamp(buf, 72, RecordingTime); // AccessTime + WriteTimestamp(buf, 84, RecordingTime); // ModificationTime + WriteTimestamp(buf, 96, RecordingTime); // AttributeTime + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(108), 1); // Checkpoint + WriteEntityId(buf, 128, ImplementationId, ImplementationSuffix); + BinaryPrimitives.WriteUInt64LittleEndian(buf.AsSpan(160), node.UniqueId); + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(168), 0); // LengthOfExtendedAttributes + return buf; + } + + /// + /// Writes a directory File Entry (file type 4). The link count is the + /// parent's reference plus one per subdirectory child, matching ECMA-167's + /// accounting of incoming directory links. + /// + private static void WriteDirectoryFe(Stream output, Node dir) { var subDirCount = dir.Children.Count(c => c.IsDirectory); - BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(48), (ushort)(1 + subDirCount)); - // icb flags at offset 34: adType=0 (short ADs) - BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(34), 0); - // info length at offset 56 — the directory's FID data is block-aligned, so - // this covers whole blocks (one short AD each). - BinaryPrimitives.WriteUInt64LittleEndian(buf.AsSpan(56), (ulong)dir.DataLength); - - // One short AD per logical block. ECMA-167 forbids a FID from crossing a - // block boundary, so BuildFidData already padded the data to a block - // multiple; describe each block with its own descriptor (length always a - // full block). Multiple ADs lift the single-block directory cap. - var blocks = dir.DataSectors; - // Short ADs live inside the FE sector after the 176-byte header, so their - // count is bounded by the sector. ~234 blocks ≈ 478 KiB of FID data, which - // is thousands of small entries; beyond that an AD-continuation extent - // would be required (not yet implemented). - var maxAds = (Sector - 176) / 8; - if (blocks > maxAds) - throw new InvalidOperationException( - $"UDF directory '{dir.Name}' needs {blocks} data blocks but only {maxAds} short " + - "allocation descriptors fit in one File Entry; AD-continuation extents are not supported."); - var lAd = blocks * 8; + var buf = BeginFileEntry(dir, fileType: 4, DirectoryPermissions, (ushort)(1 + subDirCount)); + + // One short allocation descriptor per block, the last one only as long as + // the directory's remaining bytes. OSTA UDF §2.3.10.1 requires every extent + // but the last to be a whole number of blocks; making the last one whole + // too would claim bytes the information length says are not there. + var lAd = WriteExtents(buf, 176, dir.DataLbn, dir.DataLength); BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(172), (uint)lAd); - for (var b = 0; b < blocks; b++) { - var adOff = 176 + b * 8; - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(adOff), (uint)Sector); // extent length - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(adOff + 4), (uint)(dir.DataLbn + b)); // LBN - } - // File Entry body: 176-byte header (minus 16-byte tag) + L_EA(0) + L_AD bytes. - FinalizeTag(buf, 0, FeBodyHeader + 0 + lAd); + FinalizeTag(buf, 0, FeHeaderBodySize + lAd); output.Write(buf); } - private static void WriteFileFe(Stream output, int lbn, int fileSize, int dataLbn) { - var buf = new byte[Sector]; - WriteTag(buf, 0, 261, (uint)lbn); - BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(20), 4); // ICB strategy type 4 - BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(24), 1); // max entries 1 - // Permissions at FE offset 44 — see WriteDirectoryFe. - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(44), FilePermissions); - buf[27] = 5; // file type = file (regular) - BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(48), 1); // file link count (ECMA-167 §14.9 offset) - BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(34), 0); // adType=0 short - BinaryPrimitives.WriteUInt64LittleEndian(buf.AsSpan(56), (ulong)fileSize); - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(172), 8); // L_AD = 8 - var allocLen = Math.Max(fileSize, Sector); // at least one sector - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(176), (uint)allocLen); - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(180), (uint)dataLbn); - FinalizeTag(buf, 0, FeBodyHeader + 0 + 8); + private static void WriteFileFe(Stream output, Node file) { + var buf = BeginFileEntry(file, fileType: 5, FilePermissions, linkCount: 1); + var lAd = WriteExtents(buf, 176, file.DataLbn, file.DataLength); + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(172), (uint)lAd); + FinalizeTag(buf, 0, FeHeaderBodySize + lAd); output.Write(buf); } + + /// + /// Writes the short allocation descriptors covering + /// contiguous bytes from and returns how many + /// bytes of descriptors that took. A zero-length object gets none at all. + /// + private static int WriteExtents(byte[] buf, int offset, int firstLbn, long length) { + var written = 0; + var lbn = firstLbn; + var remaining = length; + while (remaining > 0) { + var take = Math.Min(remaining, MaxExtentBytes); + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(offset + written), (uint)take); + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(offset + written + 4), (uint)lbn); + written += 8; + remaining -= take; + lbn += (int)((take + Sector - 1) / Sector); + } + + return written; + } } diff --git a/Hawkynt.FileFormats.FileSystems/REFERENCE.md b/Hawkynt.FileFormats.FileSystems/REFERENCE.md index 11339d8c4..642ac19d3 100644 --- a/Hawkynt.FileFormats.FileSystems/REFERENCE.md +++ b/Hawkynt.FileFormats.FileSystems/REFERENCE.md @@ -13585,7 +13585,7 @@ Implements `IArchiveCreatable`, `IArchiveDefragmentable`, `IArchiveFormatOperati | `OptionsSchema` | `IReadOnlyList OptionsSchema { get; }` | Tunable knobs for UDF 2.01 creation. The natural per-volume knob is the PVD Volume Identifier (ECMA-167 §7.2.5) — Linux's udf driver surfaces this string as the volume label. Image geometry is auto-sized to fit the file content. | | `TarCompressionFormatId` | `string TarCompressionFormatId { get; }` | Gets the tar compression format id. | | `Add` | `void Add(Stream archive, IReadOnlyList inputs)` | Adds (or replaces) files at the root of an existing UDF image. Uses `UdfModifier` for true random-access I/O — only the Partition Descriptor sector, the root directory's File Entry sector, the FID extent, and the new file's FE + data sectors are touched. The 32 KiB system area, VRS, AVDP, LVD, and FSD are left untouched. | -| `CanAccept` | `bool CanAccept(ArchiveInputInfo input, out string reason)` | Performs the can accept operation. | +| `CanAccept` | `bool CanAccept(ArchiveInputInfo input, out string reason)` | Accepts any file the writer can address. Short allocation descriptors cap an extent below 2^30 bytes (OSTA UDF §2.3.10.1) and only so many of them fit in one File Entry, so a file past that ceiling is declined here rather than written as a volume nothing can read. | | `CreateFromStreams` | `void CreateFromStreams(Stream output, IEnumerable inputs, FormatCreateOptions options)` | Streaming creation. UDF descriptor CRCs (FID / File Entry / VDS tags) cover only the 16-byte tag bodies, NEVER file data, and the writer emits sectors strictly forward in LBN order — so each file's body can be streamed from `OpenStream` in 64 KiB chunks straight into the sequential output when its data block is reached. No buffering of the body is required and the output is byte-identical to `Create`. | | `Create` | `void Create(Stream output, IReadOnlyList inputs, FormatCreateOptions options)` | Performs the create operation. | | `Defragment` | `void Defragment(Stream archive)` | Performs the defragment operation. | @@ -13602,7 +13602,7 @@ Implements `IArchiveCreatable`, `IArchiveDefragmentable`, `IArchiveFormatOperati #### `UdfModifier` -In-place UDF (ECMA-167) modifier — true random-access editing without rebuilding the whole image. Targeted at images produced by `UdfWriter`: 2 KiB sectors, partition starting at LBA 257, short allocation descriptors, flat root directory, plain ECMA-167 (no Metadata partition, no VAT). What it touches per Add: One sector for the new file's File Entry (allocated at the partition tail).N sectors for the new file's data (also at the tail).The root directory's FID extent — appended into trailing sector padding, or extended via a second short_ad on the root File Entry.The root File Entry sector — info length and L_AD updated, tag re-CRC'd.The Partition Descriptor sector — partition length grown.Remove uses the FID Characteristics "deleted" flag (bit 2 = 0x04) per ECMA-167 §14.4.3, which is the canonical UDF tombstone. The dead FID's identifier bytes are zeroed and its tag is re-CRC'd; the file's FE sector and data extent are zero-wiped. The root extent is never compacted (preserving existing pointers and offsets).If the layout doesn't fit — e.g. the FID extent is at the very end of the image so its growth area is occupied — the modifier falls back to allocating fresh sectors at the partition tail and adding a second short_ad to the root FE. +In-place UDF (ECMA-167) modifier — true random-access editing without rebuilding the whole image. Targeted at images produced by `UdfWriter`: 2 KiB sectors, partition starting at LBA 257, short allocation descriptors, flat root directory, plain ECMA-167 (no Metadata partition, no VAT). What it touches per Add: One sector for the new file's File Entry (allocated at the partition tail).N sectors for the new file's data (also at the tail).The root directory's FID extent — appended into trailing sector padding, or extended via a second short_ad on the root File Entry.The root File Entry sector — info length and L_AD updated, tag re-CRC'd.Every Partition Descriptor — the main sequence's and the reserve sequence's copy — grown to the partition's new length.The volume's second anchor, re-recorded in its new last block.The Logical Volume Integrity Descriptor — size, free space and the file count it advertises.Remove uses the FID Characteristics "deleted" flag (bit 2 = 0x04) per ECMA-167 §14.4.3, which is the canonical UDF tombstone. The dead FID's identifier bytes are zeroed and its tag is re-CRC'd; the file's FE sector and data extent are zero-wiped. The root extent is never compacted (preserving existing pointers and offsets).If the layout doesn't fit — e.g. the FID extent is at the very end of the image so its growth area is occupied — the modifier falls back to allocating fresh sectors at the partition tail and adding a second short_ad to the root FE. | Member | Signature | Summary | | --- | --- | --- | @@ -13626,12 +13626,12 @@ Implements `IDisposable`. #### `UdfWriter` -Writes a minimal UDF 1.02 filesystem image (ECMA-167). Builds a real directory tree from slash-separated file paths, short allocation descriptors. Computes ECMA-167 §7.2.1 DescriptorCRC (CRC-16/CCITT, init=0, poly=0x1021, non-reflected) and TagChecksum for every descriptor tag so that strict readers (xorriso, Linux udf.ko, mkudffs fsck) accept the produced images. Layout: A directory's data is a sequence of File Identifier Descriptors (FID, tag 257). The first FID of every directory is the parent entry (Parent flag 0x08, zero-length identifier, ICB pointing at the parent FE). Every directory and file is a File Entry (FE, tag 261); directories carry file type 4, regular files file type 5. A subdirectory FID carries the Directory flag 0x02 and points at the child directory's FE. +Writes a UDF 2.01 volume image (ECMA-167 plus the OSTA UDF profile). Builds a real directory tree from slash-separated file paths using short allocation descriptors, and records every descriptor the standard's own tools look for: both volume descriptor sequences, both anchors, the unallocated space and implementation use descriptors, and a closed logical volume integrity descriptor. Layout (2048-byte logical blocks): A directory's data is a dense sequence of File Identifier Descriptors (FID, tag 257) — dense because ECMA-167 lets a FID span a logical block boundary and Linux's udf driver reads directory bytes as one uninterrupted run, so padding a FID onto the next block makes the directory unreadable. The first FID of every directory is the parent entry (Parent flag 0x08, zero-length identifier). Every directory and file is a File Entry (FE, tag 261); directories carry file type 4, regular files file type 5. | Member | Signature | Summary | | --- | --- | --- | | `UdfWriter` | `UdfWriter()` | | -| `VolumeIdentifier` | `string VolumeIdentifier { get; set; }` | ECMA-167 PVD Volume Identifier (dstring at PVD offset 24, 32 bytes). Linux's udf driver surfaces this as the volume label. Default "UDF Volume". Truncated to 31 bytes (ECMA-167 dstring length byte caps at 31). | +| `VolumeIdentifier` | `string VolumeIdentifier { get; set; }` | ECMA-167 PVD Volume Identifier (dstring at PVD offset 24, 32 bytes). Linux's udf driver surfaces this as the volume label. Default "UDF Volume". | | `AddFile` | `void AddFile(string name, byte[] data)` | Performs the add file operation. | | `AddStreamingFile` | `void AddStreamingFile(string name, long size, Func openStream)` | Adds a streaming file whose body is pulled from `openStream` in 64 KiB chunks while the image is written sequentially, never buffered as a `byte[]`. `size` drives the directory/file layout. | | `WriteTo` | `void WriteTo(Stream output)` | Writes the to to the supplied output. |