Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions Compression.Tests/Refs/RefsOfflineRenameTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
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<IOException>(() => new RefsFormatDescriptor().Rename(stream, "alpha.bin", "other.bin"));
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<NotSupportedException>(() => 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();
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"));
}
[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<NotSupportedException>(() => 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<FileNotFoundException>(() => 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<NotSupportedException>(() => 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("a<b")]
[TestCase("a>b")]
[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<ArgumentException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => new RefsFormatDescriptor().Rename(stream, "alpha.bin", "beta.bin"));
}
}
29 changes: 27 additions & 2 deletions Compression.Tests/Refs/RefsSyntheticVolume.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@ namespace Compression.Tests.Refs;
/// <param name="Fragments">Number of on-disk extents the stream is split into.</param>
/// <param name="Resident">Emit a type-0x30/flags-0x01 holder without any decodable
/// extent table, which the namespace reader reports as a resident stream.</param>
/// <param name="Directory">Emit a directory-attributed type-0x30 row whose child
/// object is not published, so the namespace sees an empty directory.</param>
internal sealed record RefsSyntheticFile(
string Name,
byte[] Content,
int Fragments = 1,
bool Resident = false);
bool Resident = false,
bool Directory = false);

/// <summary>
/// Builds a byte-exact, structurally valid unmounted ReFS 3.14 image.
Expand Down Expand Up @@ -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;
}

/// <summary>
/// Pre-creates an all-zero Block Refcount row covering <paramref name="startVirtualLcn"/>.
/// Without it root #6 is empty and the first clone must materialise the row;
Expand All @@ -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;
Expand Down Expand Up @@ -433,6 +441,11 @@ private List<Row> 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])));
}
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 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

ReFS has more than one address space and more than one allocation-ownership domain, so metadata cannot be treated as anonymous raw extents.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,13 @@ public void Add(Stream archive, IReadOnlyList<ArchiveInputInfo> inputs)
public void Remove(Stream archive, string[] entryNames)
=> RefsOfflineModifier.Remove(archive, entryNames);

/// <summary>
/// Renames an existing ReFS regular file within the same parent directory
/// on an unmounted image, preserving its existing metadata and data allocation.
/// </summary>
public void Rename(Stream image, string sourcePath, string destinationPath)
=> RefsOfflineModifier.Rename(image, sourcePath, destinationPath);

private static List<ArchiveEntryInfo> ListDiagnosticSurface(Stream stream) {
var entries = new List<ArchiveEntryInfo>();
var imageLength = stream.CanSeek ? stream.Length : 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,101 @@ public static void Remove(Stream image, string[] entryNames) {
}
}

/// <summary>
/// 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.
/// </summary>
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 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];
// 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)))
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();
Expand Down
1 change: 1 addition & 0 deletions Hawkynt.FileFormats.FileSystems/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
Loading