From ee2cd4dcbc4d48d2e935856b37b2987760115a9d Mon Sep 17 00:00:00 2001 From: Hawkynt Date: Tue, 29 Sep 2026 11:31:39 +0200 Subject: [PATCH 1/4] + rename ReFS entries within an offline directory The offline ReFS editor could remove names but offered no identity-preserving rename. Replace only the directory filename key in immutable B+ pages, publish its object root and allocator roots through the alternate checkpoint, and leave the directory value and backing stream untouched. Synthetic volume tests independently check renamed bytes, allocation, metadata and collision refusal. Cross-directory moves remain gated on parent-link semantics. --- .../Refs/RefsOfflineRenameTests.cs | 70 ++++++++++++++++ .../FileSystems/FileSystem.Refs/README.md | 2 + .../FileSystem.Refs/RefsFormatDescriptor.cs | 7 ++ .../FileSystem.Refs/RefsOfflineModifier.cs | 83 +++++++++++++++++++ 4 files changed, 162 insertions(+) create mode 100644 Compression.Tests/Refs/RefsOfflineRenameTests.cs diff --git a/Compression.Tests/Refs/RefsOfflineRenameTests.cs b/Compression.Tests/Refs/RefsOfflineRenameTests.cs new file mode 100644 index 000000000..58ad57eb5 --- /dev/null +++ b/Compression.Tests/Refs/RefsOfflineRenameTests.cs @@ -0,0 +1,70 @@ +using FileSystem.Refs; + +namespace Compression.Tests.Refs; + +[TestFixture] +public sealed class RefsOfflineRenameTests { + [Test, Category("HappyPath")] + public void RenameInPlace_PreservesFileIdentityDataAndDirectoryValue() { + var content = Enumerable.Range(0, 2 * RefsSyntheticVolume.ClusterSize + 17) + .Select(i => (byte)(i * 29)).ToArray(); + var image = new RefsSyntheticVolume() + .WithFile("alpha.bin", content) + .WithFile("other.bin", [7, 8, 9]) + .Build(); + using var stream = new MemoryStream(image, writable: true); + + var before = RefsMetadataReader.Open(stream); + var source = new RefsWritableNamespace(before).ResolveStorage("alpha.bin"); + var directoryValue = source.Entry.EntryRow.Value.ToArray(); + var backingKey = source.StorageRow.Key.ToArray(); + var backingValue = source.StorageRow.Value.ToArray(); + var oldCheckpoint = before.ActiveCheckpointLcn; + var beforeProbe = new RefsImageProbe(image); + var originalClusters = beforeProbe.ReadFiles(beforeProbe.ActiveCheckpoint())["alpha.bin"].Clusters; + + new RefsFormatDescriptor().Rename(stream, "alpha.bin", "renamed.bin"); + + var after = RefsMetadataReader.Open(stream); + var renamed = new RefsWritableNamespace(after).ResolveStorage("renamed.bin"); + var paths = new RefsNamespaceReader(after).ReadAll().Select(file => file.Path).ToArray(); + var afterProbe = new RefsImageProbe(image); + var checkpoint = afterProbe.ActiveCheckpoint(); + var files = afterProbe.ReadFiles(checkpoint); + + Assert.Multiple(() => { + Assert.That(after.ActiveCheckpointLcn, Is.Not.EqualTo(oldCheckpoint)); + Assert.That(paths, Is.EquivalentTo(new[] { "renamed.bin", "other.bin" })); + Assert.That(renamed.Entry.EntryRow.Value, Is.EqualTo(directoryValue)); + Assert.That(renamed.StorageRow.Key, Is.EqualTo(backingKey)); + Assert.That(renamed.StorageRow.Value, Is.EqualTo(backingValue)); + Assert.That(files["renamed.bin"].Clusters, Is.EqualTo(originalClusters)); + Assert.That(afterProbe.ReadFileContent(files["renamed.bin"]), Is.EqualTo(content)); + Assert.That(afterProbe.ReadFileContent(files["other.bin"]), Is.EqualTo(new byte[] { 7, 8, 9 })); + }); + } + + [Test, Category("ErrorHandling")] + public void ExistingDestination_RefusesBeforeChangingImage() { + var image = new RefsSyntheticVolume() + .WithFile("alpha.bin", [1, 2, 3]) + .WithFile("other.bin", [4, 5, 6]) + .Build(); + var original = image.ToArray(); + using var stream = new MemoryStream(image, writable: true); + + Assert.Throws(() => new RefsFormatDescriptor().Rename(stream, "alpha.bin", "other.bin")); + Assert.That(image, Is.EqualTo(original)); + } + + [Test, Category("HappyPath")] + public void CaseOnlyRename_ChangesKeyWithoutReallocatingData() { + var image = new RefsSyntheticVolume().WithFile("alpha.bin", [1, 2, 3]).Build(); + using var stream = new MemoryStream(image, writable: true); + + new RefsFormatDescriptor().Rename(stream, "alpha.bin", "ALPHA.bin"); + + var metadata = RefsMetadataReader.Open(stream); + Assert.That(new RefsNamespaceReader(metadata).ReadAll().Single().Path, Is.EqualTo("ALPHA.bin")); + } +} diff --git a/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Refs/README.md b/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Refs/README.md index d458c20af..8526efcea 100644 --- a/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Refs/README.md +++ b/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Refs/README.md @@ -23,6 +23,8 @@ The implementation is intentionally not structured as a one-off defragmenter. Pa - `RefsPlacementManager` — ReFS-specific multi-pass placement policy (metadata zones, defrag and interleave). - `RefsMutationModel` — explicit `OfflineQuiescent` versus future `NativeCow` transaction boundary. +The offline editor can rename an existing file or directory within its current parent directory. It retains the directory value, file identity and backing stream rows, replacing only the filename key through immutable B+ pages and an alternate checkpoint. Cross-directory moves and mounted writes remain unsupported. + ## Placement model ReFS has more than one address space and more than one allocation-ownership domain, so metadata cannot be treated as anonymous raw extents. diff --git a/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Refs/RefsFormatDescriptor.cs b/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Refs/RefsFormatDescriptor.cs index be0b1ffa6..519f9e36f 100644 --- a/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Refs/RefsFormatDescriptor.cs +++ b/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Refs/RefsFormatDescriptor.cs @@ -268,6 +268,13 @@ public void Add(Stream archive, IReadOnlyList inputs) public void Remove(Stream archive, string[] entryNames) => RefsOfflineModifier.Remove(archive, entryNames); + /// + /// Renames an existing ReFS file or directory within the same parent directory + /// on an unmounted image, preserving its existing metadata and data allocation. + /// + public void Rename(Stream image, string sourcePath, string destinationPath) + => RefsOfflineModifier.Rename(image, sourcePath, destinationPath); + private static List ListDiagnosticSurface(Stream stream) { var entries = new List(); var imageLength = stream.CanSeek ? stream.Length : 0; diff --git a/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Refs/RefsOfflineModifier.cs b/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Refs/RefsOfflineModifier.cs index 3c5704aaa..777131083 100644 --- a/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Refs/RefsOfflineModifier.cs +++ b/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Refs/RefsOfflineModifier.cs @@ -63,6 +63,89 @@ public static void Remove(Stream image, string[] entryNames) { } } + /// + /// Renames one live entry inside its current directory on an unmounted image. + /// The directory value and any backing data row are preserved byte-for-byte; + /// only the type-0x30 filename key changes in a replacement CoW tree. + /// + public static void Rename(Stream image, string sourcePath, string destinationPath) { + ArgumentNullException.ThrowIfNull(image); + RequireWritableImage(image); + var source = NormalizePath(sourcePath); + var destination = NormalizePath(destinationPath); + var sourceSlash = source.LastIndexOf('/'); + var destinationSlash = destination.LastIndexOf('/'); + var sourceParent = sourceSlash < 0 ? string.Empty : source[..sourceSlash]; + var destinationParent = destinationSlash < 0 ? string.Empty : destination[..destinationSlash]; + if (!string.Equals(sourceParent, destinationParent, StringComparison.OrdinalIgnoreCase)) + throw new NotSupportedException("ReFS offline rename currently requires the same parent directory."); + + var newName = destination[(destinationSlash + 1)..]; + ValidateFilename(newName); + + var metadata = RefsMetadataReader.Open(image); + var files = new RefsNamespaceReader(metadata).ReadAll(); + var original = files.FirstOrDefault(file => string.Equals(file.Path, source, StringComparison.OrdinalIgnoreCase)) + ?? throw new FileNotFoundException($"ReFS entry '{source}' was not found.", source); + if (string.Equals(original.Path, destination, StringComparison.Ordinal)) return; + if (files.Any(file => !ReferenceEquals(file, original) + && string.Equals(file.Path, destination, StringComparison.OrdinalIgnoreCase))) + throw new IOException($"ReFS destination '{destination}' already exists."); + + var location = new RefsWritableNamespace(metadata).ResolveDirectoryEntry(source); + var oldKey = location.EntryRow.Key; + if (oldKey.Length < 6 || (oldKey.Length & 1) != 0 + || BinaryPrimitives.ReadUInt16LittleEndian(oldKey) != 0x30) + throw new NotSupportedException("ReFS filename key has an unsupported layout."); + var newKey = BuildRenamedKey(oldKey, newName); + + var store = new RefsCowPageStore(image, metadata); + var tree = new RefsCowBTree(image, metadata, store); + var changedDirectory = tree.Rewrite(location.ParentDirectoryRoot, virtualAddresses: true, (rows, comparer) => { + var sourceIndex = FindKey(rows, oldKey, comparer); + if (sourceIndex < 0) + throw new InvalidDataException($"ReFS source '{source}' disappeared before rename."); + + foreach (var row in rows) { + if (row.Key.Length < 4 || BinaryPrimitives.ReadUInt16LittleEndian(row.Key) != 0x30 + || comparer.Compare(row.Key, oldKey) == 0) continue; + var candidate = newKey.ToArray(); + row.Key.AsSpan(0, 4).CopyTo(candidate); + if (comparer.Compare(row.Key, candidate) == 0) + throw new IOException($"ReFS destination '{destination}' collides with an existing filename."); + } + + rows[sourceIndex] = rows[sourceIndex] with { Key = newKey }; + return true; + }); + var objectTable = new RefsCowObjectEditor(metadata, tree) + .ReplaceObjectRoot(location.ParentDirectoryOid, changedDirectory.RootReference); + PublishOfflineCheckpoint(image, metadata, store, objectTable); + } + + private static byte[] BuildRenamedKey(byte[] sourceKey, string newName) { + var nameBytes = new UnicodeEncoding(false, false, true).GetBytes(newName); + var suffix = sourceKey.AsSpan(4); + var nameLength = suffix.Length; + while (nameLength >= 2 && suffix[nameLength - 2] == 0 && suffix[nameLength - 1] == 0) + nameLength -= 2; + for (var offset = 0; offset < nameLength; offset += 2) + if (suffix[offset] == 0 && suffix[offset + 1] == 0) + throw new NotSupportedException("ReFS filename key contains embedded data beyond the decoded name."); + + var key = new byte[checked(4 + nameBytes.Length + suffix.Length - nameLength)]; + sourceKey.AsSpan(0, 4).CopyTo(key); + nameBytes.CopyTo(key, 4); + return key; + } + + private static void ValidateFilename(string name) { + if (name.Length is 0 or > 255 || name is "." or ".." + || name.EndsWith(' ') || name.EndsWith('.') + || name.Any(c => c < ' ' || "\"*/:<>?\\|".Contains(c))) + throw new ArgumentException("ReFS destination must have a valid filename of at most 255 UTF-16 units.", nameof(name)); + } + private static void ReplaceExisting(Stream image, string path, byte[] data) { var metadata = RefsMetadataReader.Open(image); var files = new RefsNamespaceReader(metadata).ReadAll(); From 4752775cf47d903f509b1731e1c3506d0e8e16ec Mon Sep 17 00:00:00 2001 From: Hawkynt Date: Tue, 29 Sep 2026 11:34:50 +0200 Subject: [PATCH 2/4] # reject ambiguous ReFS case lookup before rename A case-sensitive directory can contain entries whose names compare equal under the offline resolver's case-insensitive lookup. Refuse that ambiguous source before choosing a filename row, and pin the behavior with a synthetic image test. --- Compression.Tests/Refs/RefsOfflineRenameTests.cs | 13 +++++++++++++ .../FileSystem.Refs/RefsOfflineModifier.cs | 8 ++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/Compression.Tests/Refs/RefsOfflineRenameTests.cs b/Compression.Tests/Refs/RefsOfflineRenameTests.cs index 58ad57eb5..83a4a2b81 100644 --- a/Compression.Tests/Refs/RefsOfflineRenameTests.cs +++ b/Compression.Tests/Refs/RefsOfflineRenameTests.cs @@ -57,6 +57,19 @@ public void ExistingDestination_RefusesBeforeChangingImage() { Assert.That(image, Is.EqualTo(original)); } + [Test, Category("ErrorHandling")] + public void AmbiguousCaseSensitiveSource_RefusesBeforeChangingImage() { + var image = new RefsSyntheticVolume() + .WithFile("alpha.bin", [1]) + .WithFile("ALPHA.bin", [2]) + .Build(); + var original = image.ToArray(); + using var stream = new MemoryStream(image, writable: true); + + Assert.Throws(() => new RefsFormatDescriptor().Rename(stream, "alpha.bin", "renamed.bin")); + Assert.That(image, Is.EqualTo(original)); + } + [Test, Category("HappyPath")] public void CaseOnlyRename_ChangesKeyWithoutReallocatingData() { var image = new RefsSyntheticVolume().WithFile("alpha.bin", [1, 2, 3]).Build(); diff --git a/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Refs/RefsOfflineModifier.cs b/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Refs/RefsOfflineModifier.cs index 777131083..780cf0aeb 100644 --- a/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Refs/RefsOfflineModifier.cs +++ b/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Refs/RefsOfflineModifier.cs @@ -85,8 +85,12 @@ public static void Rename(Stream image, string sourcePath, string destinationPat var metadata = RefsMetadataReader.Open(image); var files = new RefsNamespaceReader(metadata).ReadAll(); - var original = files.FirstOrDefault(file => string.Equals(file.Path, source, StringComparison.OrdinalIgnoreCase)) - ?? throw new FileNotFoundException($"ReFS entry '{source}' was not found.", source); + var matches = files.Where(file => string.Equals(file.Path, source, StringComparison.OrdinalIgnoreCase)).ToArray(); + if (matches.Length == 0) + throw new FileNotFoundException($"ReFS entry '{source}' was not found.", source); + if (matches.Length != 1) + throw new NotSupportedException($"ReFS source '{source}' is ambiguous under case-insensitive lookup."); + var original = matches[0]; if (string.Equals(original.Path, destination, StringComparison.Ordinal)) return; if (files.Any(file => !ReferenceEquals(file, original) && string.Equals(file.Path, destination, StringComparison.OrdinalIgnoreCase))) From 2fc6da51fa3488af032386640e0eb03fd3464d66 Mon Sep 17 00:00:00 2001 From: Hawkynt Date: Wed, 30 Sep 2026 13:50:04 +0200 Subject: [PATCH 3/4] # ReFS offline rename refuses directories it cannot rename completely Symptom: the offline rename accepted a directory and rewrote only its type-0x30 filename key, while the README advertised directory renames that no test had ever exercised. Root cause: a directory is also reached through its own object and through the link rows its parent keeps for it; none of those rows is decoded, so a directory rename could leave the old name behind. Fix: refuse a directory source before any page is written, restrict the API docs and README to regular files, and state that the rename is verified against synthetic volumes only. The synthetic volume can now emit an empty directory entry; new tests pin directory refusal, missing source, cross-directory moves, invalid names, the 255/256-unit length boundary, a no-op rename and a read-only stream, each checking the image stays byte-identical. --- .../Refs/RefsOfflineRenameTests.cs | 97 +++++++++++++++++++ Compression.Tests/Refs/RefsSyntheticVolume.cs | 29 +++++- .../FileSystems/FileSystem.Refs/README.md | 2 +- .../FileSystem.Refs/RefsFormatDescriptor.cs | 2 +- .../FileSystem.Refs/RefsOfflineModifier.cs | 10 +- 5 files changed, 135 insertions(+), 5 deletions(-) diff --git a/Compression.Tests/Refs/RefsOfflineRenameTests.cs b/Compression.Tests/Refs/RefsOfflineRenameTests.cs index 83a4a2b81..d399a663d 100644 --- a/Compression.Tests/Refs/RefsOfflineRenameTests.cs +++ b/Compression.Tests/Refs/RefsOfflineRenameTests.cs @@ -80,4 +80,101 @@ public void CaseOnlyRename_ChangesKeyWithoutReallocatingData() { var metadata = RefsMetadataReader.Open(stream); Assert.That(new RefsNamespaceReader(metadata).ReadAll().Single().Path, Is.EqualTo("ALPHA.bin")); } + [Test, Category("ErrorHandling")] + public void GivenDirectoryEntry_WhenRenamed_ThenRefusesBeforeChangingImage() { + var image = new RefsSyntheticVolume() + .WithDirectoryEntry("folder") + .WithFile("alpha.bin", [1, 2, 3]) + .Build(); + var original = image.ToArray(); + using var stream = new MemoryStream(image, writable: true); + + Assert.Throws(() => new RefsFormatDescriptor().Rename(stream, "folder", "renamed")); + Assert.That(image, Is.EqualTo(original)); + } + + [Test, Category("ErrorHandling")] + public void GivenMissingSource_WhenRenamed_ThenThrowsFileNotFoundWithoutChangingImage() { + var image = new RefsSyntheticVolume().WithFile("alpha.bin", [1]).Build(); + var original = image.ToArray(); + using var stream = new MemoryStream(image, writable: true); + + Assert.Throws(() => new RefsFormatDescriptor().Rename(stream, "missing.bin", "renamed.bin")); + Assert.That(image, Is.EqualTo(original)); + } + + [Test, Category("ErrorHandling")] + public void GivenDestinationInOtherDirectory_WhenRenamed_ThenRefusesCrossDirectoryMove() { + var image = new RefsSyntheticVolume().WithFile("alpha.bin", [1]).Build(); + var original = image.ToArray(); + using var stream = new MemoryStream(image, writable: true); + + Assert.Throws(() => new RefsFormatDescriptor().Rename(stream, "alpha.bin", "sub/alpha.bin")); + Assert.That(image, Is.EqualTo(original)); + } + + [TestCase("")] + [TestCase(".")] + [TestCase("..")] + [TestCase("trailing.")] + [TestCase("trailing ")] + [TestCase("a:b")] + [TestCase("a*b")] + [TestCase("a?b")] + [TestCase("a\"b")] + [TestCase("ab")] + [TestCase("a|b")] + [TestCase("tab name")] + [Category("ErrorHandling")] + public void GivenInvalidDestinationName_WhenRenamed_ThenThrowsArgumentWithoutChangingImage(string name) { + var image = new RefsSyntheticVolume().WithFile("alpha.bin", [1]).Build(); + var original = image.ToArray(); + using var stream = new MemoryStream(image, writable: true); + + Assert.Throws(() => new RefsFormatDescriptor().Rename(stream, "alpha.bin", name)); + Assert.That(image, Is.EqualTo(original)); + } + + [Test, Category("Boundary")] + public void GivenDestinationOf255Units_WhenRenamed_ThenAcceptsIt() { + var name = new string('n', 251) + ".bin"; + var image = new RefsSyntheticVolume().WithFile("alpha.bin", [4, 5, 6]).Build(); + using var stream = new MemoryStream(image, writable: true); + + new RefsFormatDescriptor().Rename(stream, "alpha.bin", name); + + var files = new RefsNamespaceReader(RefsMetadataReader.Open(stream)).ReadAll(); + Assert.That(files.Single().Path, Is.EqualTo(name)); + } + + [Test, Category("Boundary")] + public void GivenDestinationOf256Units_WhenRenamed_ThenRefusesWithoutChangingImage() { + var name = new string('n', 252) + ".bin"; + var image = new RefsSyntheticVolume().WithFile("alpha.bin", [4, 5, 6]).Build(); + var original = image.ToArray(); + using var stream = new MemoryStream(image, writable: true); + + Assert.Throws(() => new RefsFormatDescriptor().Rename(stream, "alpha.bin", name)); + Assert.That(image, Is.EqualTo(original)); + } + + [Test, Category("EdgeCase")] + public void GivenIdenticalSourceAndDestination_WhenRenamed_ThenLeavesImageUntouched() { + var image = new RefsSyntheticVolume().WithFile("alpha.bin", [1]).Build(); + var original = image.ToArray(); + using var stream = new MemoryStream(image, writable: true); + + new RefsFormatDescriptor().Rename(stream, "alpha.bin", "alpha.bin"); + + Assert.That(image, Is.EqualTo(original)); + } + + [Test, Category("ErrorHandling")] + public void GivenReadOnlyStream_WhenRenamed_ThenRefuses() { + var image = new RefsSyntheticVolume().WithFile("alpha.bin", [1]).Build(); + using var stream = new MemoryStream(image, writable: false); + + Assert.Throws(() => new RefsFormatDescriptor().Rename(stream, "alpha.bin", "beta.bin")); + } } diff --git a/Compression.Tests/Refs/RefsSyntheticVolume.cs b/Compression.Tests/Refs/RefsSyntheticVolume.cs index c6c628352..7a8e431b2 100644 --- a/Compression.Tests/Refs/RefsSyntheticVolume.cs +++ b/Compression.Tests/Refs/RefsSyntheticVolume.cs @@ -10,11 +10,14 @@ namespace Compression.Tests.Refs; /// Number of on-disk extents the stream is split into. /// Emit a type-0x30/flags-0x01 holder without any decodable /// extent table, which the namespace reader reports as a resident stream. +/// Emit a directory-attributed type-0x30 row whose child +/// object is not published, so the namespace sees an empty directory. internal sealed record RefsSyntheticFile( string Name, byte[] Content, int Fragments = 1, - bool Resident = false); + bool Resident = false, + bool Directory = false); /// /// Builds a byte-exact, structurally valid unmounted ReFS 3.14 image. @@ -87,6 +90,11 @@ public RefsSyntheticVolume WithResidentFile(string name, byte[] content) { return this; } + public RefsSyntheticVolume WithDirectoryEntry(string name) { + this._files.Add(new RefsSyntheticFile(name, [], 1, Directory: true)); + return this; + } + /// /// Pre-creates an all-zero Block Refcount row covering . /// Without it root #6 is empty and the first clone must materialise the row; @@ -110,7 +118,7 @@ public byte[] Build() { foreach (var file in this._files) { fileIds[file.Name] = nextFileId++; - if (file.Resident) { + if (file.Resident || file.Directory) { fileClusters[file.Name] = []; fileExtents[file.Name] = []; continue; @@ -433,6 +441,11 @@ private List DirectoryRows( continue; } + if (file.Directory) { + rows.Add(new Row(NameKey(file.Name, 0x02), DirectoryRecordValue(fileId))); + continue; + } + rows.Add(new Row(NameKey(file.Name, 0x02), FileRecordValue(fileId, size, allocated))); rows.Add(new Row(BackingKey(fileId), BackingValue(size, allocated, fileExtents[file.Name]))); } @@ -460,6 +473,18 @@ private static byte[] FileRecordValue(ulong fileId, ulong size, ulong allocated) return value; } + // A directory row carries the child directory's own OID at +0x08. The OID is + // deliberately absent from the Object Table, so the reader lists the entry + // without descending into it. + private static byte[] DirectoryRecordValue(ulong fileId) { + var value = new byte[0x48]; + BinaryPrimitives.WriteUInt64LittleEndian(value.AsSpan(0x00, 8), fileId); + BinaryPrimitives.WriteUInt64LittleEndian(value.AsSpan(0x08, 8), 0x7000 + fileId); + BinaryPrimitives.WriteUInt64LittleEndian(value.AsSpan(0x18, 8), 0x01D0_0000_0000_0000UL); + BinaryPrimitives.WriteUInt32LittleEndian(value.AsSpan(0x40, 4), 0x10000000); + return value; + } + private static byte[] ResidentHolderValue(ulong size) { var value = new byte[0x80]; BinaryPrimitives.WriteUInt64LittleEndian(value.AsSpan(0x30, 8), 0x01D0_0000_0000_0000UL); diff --git a/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Refs/README.md b/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Refs/README.md index 8526efcea..37fb75e8f 100644 --- a/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Refs/README.md +++ b/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Refs/README.md @@ -23,7 +23,7 @@ The implementation is intentionally not structured as a one-off defragmenter. Pa - `RefsPlacementManager` — ReFS-specific multi-pass placement policy (metadata zones, defrag and interleave). - `RefsMutationModel` — explicit `OfflineQuiescent` versus future `NativeCow` transaction boundary. -The offline editor can rename an existing file or directory within its current parent directory. It retains the directory value, file identity and backing stream rows, replacing only the filename key through immutable B+ pages and an alternate checkpoint. Cross-directory moves and mounted writes remain unsupported. +The offline editor can rename an existing regular file within its current parent directory. It retains the directory value, file identity and backing stream rows, replacing only the filename key through immutable B+ pages and an alternate checkpoint. Directory renames are refused because the rows that link a directory object back to its name are not decoded yet; cross-directory moves and mounted writes remain unsupported. The rename is verified against synthetic volumes only — no external ReFS reader has yet accepted a renamed image. ## Placement model diff --git a/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Refs/RefsFormatDescriptor.cs b/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Refs/RefsFormatDescriptor.cs index 519f9e36f..078d84887 100644 --- a/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Refs/RefsFormatDescriptor.cs +++ b/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Refs/RefsFormatDescriptor.cs @@ -269,7 +269,7 @@ public void Remove(Stream archive, string[] entryNames) => RefsOfflineModifier.Remove(archive, entryNames); /// - /// Renames an existing ReFS file or directory within the same parent directory + /// Renames an existing ReFS regular file within the same parent directory /// on an unmounted image, preserving its existing metadata and data allocation. /// public void Rename(Stream image, string sourcePath, string destinationPath) diff --git a/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Refs/RefsOfflineModifier.cs b/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Refs/RefsOfflineModifier.cs index 780cf0aeb..f454d9ef8 100644 --- a/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Refs/RefsOfflineModifier.cs +++ b/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Refs/RefsOfflineModifier.cs @@ -64,7 +64,7 @@ public static void Remove(Stream image, string[] entryNames) { } /// - /// Renames one live entry inside its current directory on an unmounted image. + /// Renames one live regular file inside its current directory on an unmounted image. /// The directory value and any backing data row are preserved byte-for-byte; /// only the type-0x30 filename key changes in a replacement CoW tree. /// @@ -91,6 +91,14 @@ public static void Rename(Stream image, string sourcePath, string destinationPat if (matches.Length != 1) throw new NotSupportedException($"ReFS source '{source}' is ambiguous under case-insensitive lookup."); var original = matches[0]; + // A directory is referenced by more than its filename row: its own object + // and any ID-to-name link the parent keeps for it would still carry the old + // name. None of those rows is decoded or proven yet, so a directory rename + // is refused rather than left half-applied. + if (original.IsDirectory) + throw new NotSupportedException( + $"ReFS offline rename is limited to regular files; renaming the directory '{source}' is withheld " + + "until its directory-link rows are proven for the active ReFS profile."); if (string.Equals(original.Path, destination, StringComparison.Ordinal)) return; if (files.Any(file => !ReferenceEquals(file, original) && string.Equals(file.Path, destination, StringComparison.OrdinalIgnoreCase))) From df2515c6e35b2af724a4d768d50b393a5020ed92 Mon Sep 17 00:00:00 2001 From: Hawkynt Date: Wed, 30 Sep 2026 20:05:46 +0200 Subject: [PATCH 4/4] * regenerate the API references --- Hawkynt.FileFormats.FileSystems/REFERENCE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/Hawkynt.FileFormats.FileSystems/REFERENCE.md b/Hawkynt.FileFormats.FileSystems/REFERENCE.md index 877a539b2..1b8646c5a 100644 --- a/Hawkynt.FileFormats.FileSystems/REFERENCE.md +++ b/Hawkynt.FileFormats.FileSystems/REFERENCE.md @@ -12030,6 +12030,7 @@ Implements `IArchiveDefragmentable`, `IArchiveFormatOperations`, `IArchiveModifi | `OpenFilesystem` | `IFilesystemSession OpenFilesystem(Stream image, FilesystemOpenOptions options)` | Opens a filesystem session over the image. | | `ProbeFilesystem` | `FilesystemDriverProfile ProbeFilesystem(Stream image)` | Probes the image and reports the filesystem driver profile. | | `Remove` | `void Remove(Stream archive, string[] entryNames)` | Removes regular files or empty directories from an unmounted ReFS image. Namespace deletion is published through immutable B+ replacement pages and the alternate CHKP. | +| `Rename` | `void Rename(Stream image, string sourcePath, string destinationPath)` | Renames an existing ReFS regular file within the same parent directory on an unmounted image, preserving its existing metadata and data allocation. | #### `RefsOfflineBlockCloner`