From 2c2ed279f739734a038ffbcb697f3073a9067bee Mon Sep 17 00:00:00 2001 From: Devedse Date: Fri, 19 Jun 2026 13:24:05 +0200 Subject: [PATCH 1/5] Fix InvalidCastException in SubKeyIndirectListCell write paths for mixed ri/li lists SubKeyIndirectListCell backs both "ri" index-root lists (entries are subordinate lists) and "li" leaf-index lists (entries are key nodes). The read paths (Count, EnumerateKeyNames, EnumerateKeys, DoFindKey, KeyFinder) already tolerate a single list whose entries are a mix of sublists and key nodes, by dispatching on the actual cell type. But LinkSubKey/UnlinkSubKey still branched on ListType and hard-cast every entry (GetCell for "ri", GetCell for "li"). On a real Windows SYSTEM hive whose "ri" list contains a direct key-node entry, deleting a subkey (UnlinkSubKey) threw: System.InvalidCastException: Unable to cast object of type 'DiscUtils.Registry.KeyNodeCell' to type 'DiscUtils.Registry.ListCell'. Reading/enumerating the same key worked (those paths were already hardened); only add/delete crashed. Fix: LinkSubKey/UnlinkSubKey now dispatch on the actual cell type per entry (GetCell + "is ListCell"), exactly as the read paths do. Behaviour for homogeneous "ri"/"li" lists is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SubKeyIndirectListCell.cs | 65 +++++++------------ 1 file changed, 23 insertions(+), 42 deletions(-) diff --git a/Library/DiscUtils.Registry/SubKeyIndirectListCell.cs b/Library/DiscUtils.Registry/SubKeyIndirectListCell.cs index 69ceae2ea..28dece1ce 100644 --- a/Library/DiscUtils.Registry/SubKeyIndirectListCell.cs +++ b/Library/DiscUtils.Registry/SubKeyIndirectListCell.cs @@ -160,33 +160,22 @@ internal override IEnumerable EnumerateKeys() internal override int LinkSubKey(string name, int cellIndex) { - // Look for the first sublist that has a subkey name greater than name - if (ListType == "ri") + // As in UnlinkSubKey, a list's entries can be a mix of sublists ("ri") and key nodes ("li"), so dispatch + // on the actual cell type rather than on ListType (avoids a KeyNodeCell -> ListCell InvalidCastException). + // Look for the first sublist/key node whose name is greater than the new name. + for (var i = 0; i < CellIndexes.Count; ++i) { - if (CellIndexes.Count == 0) - { - throw new NotImplementedException("Empty indirect list"); - } - - for (var i = 0; i < CellIndexes.Count - 1; ++i) + var cell = _hive.GetCell(CellIndexes[i]); + if (cell is ListCell listCell) { - var cell = _hive.GetCell(CellIndexes[i]); - if (cell.FindKey(name, out var tempIndex) <= 0) + // Descend into the last sublist, or the first whose range can already hold the new name. + if (i == CellIndexes.Count - 1 || listCell.FindKey(name, out _) <= 0) { - CellIndexes[i] = cell.LinkSubKey(name, cellIndex); + CellIndexes[i] = listCell.LinkSubKey(name, cellIndex); return _hive.UpdateCell(this, false); } } - - var lastCell = _hive.GetCell(CellIndexes[CellIndexes.Count - 1]); - CellIndexes[CellIndexes.Count - 1] = lastCell.LinkSubKey(name, cellIndex); - return _hive.UpdateCell(this, false); - } - - for (var i = 0; i < CellIndexes.Count; ++i) - { - var cell = _hive.GetCell(CellIndexes[i]); - if (string.Compare(name, cell.Name, StringComparison.OrdinalIgnoreCase) < 0) + else if (string.Compare(name, ((KeyNodeCell)cell).Name, StringComparison.OrdinalIgnoreCase) < 0) { CellIndexes.Insert(i, cellIndex); return _hive.UpdateCell(this, true); @@ -199,20 +188,19 @@ internal override int LinkSubKey(string name, int cellIndex) internal override int UnlinkSubKey(string name) { - if (ListType == "ri") + // A "ri" list references sublists and a "li" list references key nodes, but in practice the entries of a + // single list can be a mix of both (the read paths - Count/EnumerateKeyNames/EnumerateKeys/DoFindKey - + // already tolerate this). Dispatch on the actual cell type instead of on ListType, otherwise deleting a + // key whose containing list has a mismatched entry throws InvalidCastException (KeyNodeCell -> ListCell). + for (var i = 0; i < CellIndexes.Count; ++i) { - if (CellIndexes.Count == 0) - { - throw new NotImplementedException("Empty indirect list"); - } - - for (var i = 0; i < CellIndexes.Count; ++i) + var cell = _hive.GetCell(CellIndexes[i]); + if (cell is ListCell listCell) { - var cell = _hive.GetCell(CellIndexes[i]); - if (cell.FindKey(name, out var tempIndex) <= 0) + if (listCell.FindKey(name, out _) <= 0) { - CellIndexes[i] = cell.UnlinkSubKey(name); - if (cell.Count == 0) + CellIndexes[i] = listCell.UnlinkSubKey(name); + if (listCell.Count == 0) { _hive.FreeCell(CellIndexes[i]); CellIndexes.RemoveAt(i); @@ -221,17 +209,10 @@ internal override int UnlinkSubKey(string name) return _hive.UpdateCell(this, false); } } - } - else - { - for (var i = 0; i < CellIndexes.Count; ++i) + else if (string.Equals(name, ((KeyNodeCell)cell).Name, StringComparison.OrdinalIgnoreCase)) { - var cell = _hive.GetCell(CellIndexes[i]); - if (string.Equals(name, cell.Name, StringComparison.OrdinalIgnoreCase)) - { - CellIndexes.RemoveAt(i); - return _hive.UpdateCell(this, true); - } + CellIndexes.RemoveAt(i); + return _hive.UpdateCell(this, true); } } From e16a67c16555bd4373fef330f84edbc648ba2561 Mon Sep 17 00:00:00 2001 From: Devedse Date: Fri, 19 Jun 2026 15:03:08 +0200 Subject: [PATCH 2/5] Fix Bin.AllocateCell cell-size alignment check (& 0x7, not % 0x7) Registry hive cells are 8-byte aligned, so AllocateCell must reject sizes whose low 3 bits are set: (size & 0x7) != 0. Commit f7bf7550ff632be3169e0515f3961206667df14e ("Minor code cleanups and optimizations") changed this from `size % 8 != 0` to `(size % 0x7) != 0` (mod 7), which rejects ~6 of every 7 valid sizes - including the ~80-byte root key cell - so RegistryHive.Create() throws "Invalid cell size" and every registry/BootConfig test fails at construction (and the test run hangs). Restores the 8-byte alignment check. Co-Authored-By: Claude Opus 4.8 (1M context) --- Library/DiscUtils.Registry/Bin.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Library/DiscUtils.Registry/Bin.cs b/Library/DiscUtils.Registry/Bin.cs index cb65d72c0..f5ddaeebd 100644 --- a/Library/DiscUtils.Registry/Bin.cs +++ b/Library/DiscUtils.Registry/Bin.cs @@ -187,7 +187,7 @@ internal bool WriteRawCellData(int cellIndex, ReadOnlySpan data) internal int AllocateCell(int size) { - if (size < 8 || (size % 0x7) != 0) + if (size < 8 || (size & 0x7) != 0) { throw new ArgumentException("Invalid cell size"); } From 7640e39daf7f793ab6e93b0d1bfbcf5177757b06 Mon Sep 17 00:00:00 2001 From: Devedse Date: Fri, 19 Jun 2026 15:25:56 +0200 Subject: [PATCH 3/5] Fix NtfsFileSystem.GetFileLength ignoring FileLengthFromDirectoryEntries (regressed in a5516351) GetFileLength must honor the NtfsOptions.FileLengthFromDirectoryEntries option: when set (the default), a default-data-stream length request returns the directory entry's cached RealSize. Commit a5516351132e259b5149030e000f4789f78894d2 ("Bugfixes for FileExist and GetFileLength") removed that shortcut, so GetFileLength always looked up the data attribute and the option became a no-op - breaking NtfsFileSystemTest.GetFileLength (and the async variant), which expects a hardlink's stale directory-entry size (14325) by default and the live data-attribute size (50) only when the option is disabled. Restores the shortcut. The FileExists fix in that commit is correct and left intact. Co-Authored-By: Claude Opus 4.8 (1M context) --- Library/DiscUtils.Ntfs/NtfsFileSystem.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Library/DiscUtils.Ntfs/NtfsFileSystem.cs b/Library/DiscUtils.Ntfs/NtfsFileSystem.cs index 34e7d6ecb..abb0149fd 100644 --- a/Library/DiscUtils.Ntfs/NtfsFileSystem.cs +++ b/Library/DiscUtils.Ntfs/NtfsFileSystem.cs @@ -930,6 +930,13 @@ public override long GetFileLength(string path) var dirEntry = GetDirectoryEntry(dirEntryPath) ?? throw new FileNotFoundException("File not found", path); + // Ordinary file length request, use info from the directory entry for efficiency - if allowed. + if (NtfsOptions.FileLengthFromDirectoryEntries && attributeName == null && + attributeType == AttributeType.Data) + { + return (long)dirEntry.Details.RealSize; + } + var file = GetFile(dirEntry.Reference); var attr = file.GetAttribute(attributeType, attributeName) ?? throw new FileNotFoundException($"No such attribute '{attributeName}({attributeType})'"); From 07e74d2aa037d1cd9e1b8bc554e975682671cacc Mon Sep 17 00:00:00 2001 From: Devedse Date: Fri, 19 Jun 2026 16:19:05 +0200 Subject: [PATCH 4/5] Fix infinite loop in DynamicStream sector scan (regressed in f7bf7550) FindNextPresentSector/FindNextAbsentSector advance with pos += (8 - sectorInBlock & 0x7) * Sizes.Sector. C# precedence makes this (8 - sectorInBlock) & 0x7, not 8 - (sectorInBlock & 0x7): when sectorInBlock is byte-aligned and the block-bitmap byte is empty, the original (8 - sectorInBlock % 8) advanced by 8 sectors, but the rewrite yields 8 & 7 = 0, so pos never advances and the extent enumeration (LayerExtents/Extents and the dynamic VHD/VMDK builders) loops forever. Commit f7bf7550 ("Minor code cleanups and optimizations") introduced this by rewriting % 8 to & 0x7 without parentheses. Parenthesized so the mask applies to sectorInBlock (the 7 - x & 0x7 mask forms are coincidentally equivalent but were parenthesized too for clarity). Co-Authored-By: Claude Opus 4.8 (1M context) --- Library/DiscUtils.Vhd/DynamicStream.cs | 36 +++++++++++++------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/Library/DiscUtils.Vhd/DynamicStream.cs b/Library/DiscUtils.Vhd/DynamicStream.cs index ed05fff85..021f69acf 100644 --- a/Library/DiscUtils.Vhd/DynamicStream.cs +++ b/Library/DiscUtils.Vhd/DynamicStream.cs @@ -174,7 +174,7 @@ public override IEnumerable MapContent(long start, long length) if (offsetInSector != 0 || toRead < Sizes.Sector) { - var mask = (byte)(1 << (7 - sectorInBlock & 0x7)); + var mask = (byte)(1 << (7 - (sectorInBlock & 0x7))); if ((blockBitmap[sectorInBlock / 8] & mask) != 0) { var extentStart = (_blockAllocationTable[block] + sectorInBlock) * @@ -190,7 +190,7 @@ public override IEnumerable MapContent(long start, long length) // Processing at least one whole sector, read as many as possible var toReadSectors = toRead / Sizes.Sector; - var mask = (byte)(1 << (7 - sectorInBlock & 0x7)); + var mask = (byte)(1 << (7 - (sectorInBlock & 0x7))); var readFromParent = (blockBitmap[sectorInBlock / 8] & mask) == 0; var numSectors = 1; @@ -274,7 +274,7 @@ public override int Read(byte[] buffer, int offset, int count) if (offsetInSector != 0 || toRead < Sizes.Sector) { - var mask = (byte)(1 << (7 - sectorInBlock & 0x7)); + var mask = (byte)(1 << (7 - (sectorInBlock & 0x7))); if ((blockBitmap[sectorInBlock / 8] & mask) != 0) { @@ -296,7 +296,7 @@ public override int Read(byte[] buffer, int offset, int count) // Processing at least one whole sector, read as many as possible var toReadSectors = toRead / Sizes.Sector; - var mask = (byte)(1 << (7 - sectorInBlock & 0x7)); + var mask = (byte)(1 << (7 - (sectorInBlock & 0x7))); var readFromParent = (blockBitmap[sectorInBlock / 8] & mask) == 0; var numSectors = 1; @@ -377,7 +377,7 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation if (offsetInSector != 0 || toRead < Sizes.Sector) { - var mask = (byte)(1 << (7 - sectorInBlock & 0x7)); + var mask = (byte)(1 << (7 - (sectorInBlock & 0x7))); if ((blockBitmap[sectorInBlock / 8] & mask) != 0) { _fileStream.Position = (_blockAllocationTable[block] + sectorInBlock) * @@ -398,7 +398,7 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation // Processing at least one whole sector, read as many as possible var toReadSectors = toRead / Sizes.Sector; - var mask = (byte)(1 << (7 - sectorInBlock & 0x7)); + var mask = (byte)(1 << (7 - (sectorInBlock & 0x7))); var readFromParent = (blockBitmap[sectorInBlock / 8] & mask) == 0; var numSectors = 1; @@ -479,7 +479,7 @@ public override int Read(Span buffer) if (offsetInSector != 0 || toRead < Sizes.Sector) { - var mask = (byte)(1 << (7 - sectorInBlock & 0x7)); + var mask = (byte)(1 << (7 - (sectorInBlock & 0x7))); if ((blockBitmap[sectorInBlock / 8] & mask) != 0) { _fileStream.Position = (_blockAllocationTable[block] + sectorInBlock) * @@ -500,7 +500,7 @@ public override int Read(Span buffer) // Processing at least one whole sector, read as many as possible var toReadSectors = toRead / Sizes.Sector; - var mask = (byte)(1 << (7 - sectorInBlock & 0x7)); + var mask = (byte)(1 << (7 - (sectorInBlock & 0x7))); var readFromParent = (blockBitmap[sectorInBlock / 8] & mask) == 0; var numSectors = 1; @@ -621,7 +621,7 @@ public override void Write(byte[] buffer, int offset, int count) // Reduce the write to just the end of the current sector toWrite = Math.Min(count - numWritten, Sizes.Sector - offsetInSector); - var sectorMask = (byte)(1 << (7 - sectorInBlock & 0x7)); + var sectorMask = (byte)(1 << (7 - (sectorInBlock & 0x7))); var sectorStart = (_blockAllocationTable[block] + sectorInBlock) * Sizes.Sector + _blockBitmapSize; @@ -665,7 +665,7 @@ public override void Write(byte[] buffer, int offset, int count) // Update all of the bits in the block bitmap for (var i = offset; i < offset + toWrite; i += Sizes.Sector) { - var sectorMask = (byte)(1 << (7 - sectorInBlock & 0x7)); + var sectorMask = (byte)(1 << (7 - (sectorInBlock & 0x7))); if ((_blockBitmaps[block][sectorInBlock / 8] & sectorMask) == 0) { _blockBitmaps[block][sectorInBlock / 8] |= sectorMask; @@ -726,7 +726,7 @@ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, Cancella // Reduce the write to just the end of the current sector toWrite = Math.Min(buffer.Length - numWritten, Sizes.Sector - offsetInSector); - var sectorMask = (byte)(1 << (7 - sectorInBlock & 0x7)); + var sectorMask = (byte)(1 << (7 - (sectorInBlock & 0x7))); var sectorStart = (_blockAllocationTable[block] + sectorInBlock) * Sizes.Sector + _blockBitmapSize; @@ -778,7 +778,7 @@ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, Cancella // Update all of the bits in the block bitmap for (var i = 0; i < toWrite; i += Sizes.Sector) { - var sectorMask = (byte)(1 << (7 - sectorInBlock & 0x7)); + var sectorMask = (byte)(1 << (7 - (sectorInBlock & 0x7))); if ((_blockBitmaps[block][sectorInBlock / 8] & sectorMask) == 0) { _blockBitmaps[block][sectorInBlock / 8] |= sectorMask; @@ -841,7 +841,7 @@ public override void Write(ReadOnlySpan buffer) // Reduce the write to just the end of the current sector toWrite = Math.Min(buffer.Length - numWritten, Sizes.Sector - offsetInSector); - var sectorMask = (byte)(1 << (7 - sectorInBlock & 0x7)); + var sectorMask = (byte)(1 << (7 - (sectorInBlock & 0x7))); var sectorStart = (_blockAllocationTable[block] + sectorInBlock) * Sizes.Sector + _blockBitmapSize; @@ -885,7 +885,7 @@ public override void Write(ReadOnlySpan buffer) // Update all of the bits in the block bitmap for (var i = 0; i < toWrite; i += Sizes.Sector) { - var sectorMask = (byte)(1 << (7 - sectorInBlock & 0x7)); + var sectorMask = (byte)(1 << (7 - (sectorInBlock & 0x7))); if ((_blockBitmaps[block][sectorInBlock / 8] & sectorMask) == 0) { _blockBitmaps[block][sectorInBlock / 8] |= sectorMask; @@ -977,11 +977,11 @@ private long FindNextPresentSector(long pos, long maxPos) if (_blockBitmaps[block][sectorInBlock / 8] == 0) { - pos += (8 - sectorInBlock & 0x7) * Sizes.Sector; + pos += (8 - (sectorInBlock & 0x7)) * Sizes.Sector; } else { - var mask = (byte)(1 << (7 - sectorInBlock & 0x7)); + var mask = (byte)(1 << (7 - (sectorInBlock & 0x7))); if ((_blockBitmaps[block][sectorInBlock / 8] & mask) != 0) { foundStart = true; @@ -1015,11 +1015,11 @@ private long FindNextAbsentSector(long pos, long maxPos) if (_blockBitmaps[block][sectorInBlock / 8] == 0xFF) { - pos += (8 - sectorInBlock & 0x7) * Sizes.Sector; + pos += (8 - (sectorInBlock & 0x7)) * Sizes.Sector; } else { - var mask = (byte)(1 << (7 - sectorInBlock & 0x7)); + var mask = (byte)(1 << (7 - (sectorInBlock & 0x7))); if ((_blockBitmaps[block][sectorInBlock / 8] & mask) == 0) { foundEnd = true; From 43df6ed6902bf9357f91a692fff9ede9db97e9c8 Mon Sep 17 00:00:00 2001 From: Devedse Date: Fri, 19 Jun 2026 16:19:06 +0200 Subject: [PATCH 5/5] Fix SynchronizedSparseStream Position never advancing (regressed in 4b5b75df) The synchronized wrapper kept its own Position auto-property and set content.Position = Position before each operation but never wrote the advanced position back afterward, so Position stayed frozen and reads/writes through the wrapper re-processed the same offset (e.g. ExFat ReadLongSparseLimited read the wrong data). Delegate Position to content.Position under the lock - correct for this single-shared- cursor synchronized wrapper and fixes every read/write path (sync and async) at once. Introduced by 4b5b75df ("Synchronized wrappers for disks and streams"). Co-Authored-By: Claude Opus 4.8 (1M context) --- Library/DiscUtils.Streams/SparseStream.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Library/DiscUtils.Streams/SparseStream.cs b/Library/DiscUtils.Streams/SparseStream.cs index ca96bd1a2..1f281f0b8 100644 --- a/Library/DiscUtils.Streams/SparseStream.cs +++ b/Library/DiscUtils.Streams/SparseStream.cs @@ -656,7 +656,11 @@ private sealed class SynchronizedSparseStream(SparseStream content, Ownership ow public override long Length => content.Length; - public override long Position { get; set; } + public override long Position + { + get { lock (sync) { return content.Position; } } + set { lock (sync) { content.Position = value; } } + } public override void Flush() {