Skip to content

Commit 809448a

Browse files
authored
# ambiguous ReFS writable namespace paths are refused (#421)
* # reject ambiguous ReFS writable namespace paths Case-insensitive writable lookups selected the first matching row even when a directory contained names distinguished only by case. This could direct replacement, deletion or block clone at an unintended file. Require unique resolution both in the namespace walker and at the mutation entry points, and verify ambiguous synthetic images remain unchanged. The evidence is the existing ReFS row model and synthetic case-collision fixture; no external on-disk grammar is inferred. * * ReFS ambiguity tests prove the ambiguity check fired Symptom: the offline-mutation test asserted only NotSupportedException, which Add also throws for a path it will not create, so the test passed even with the ambiguity check removed. Fix: assert the ambiguity message for every refused mutation, cover an ambiguous clone destination as well as an ambiguous source, and add the equivalence class of a unique case-folded path that must still resolve.
1 parent ea57c66 commit 809448a

5 files changed

Lines changed: 89 additions & 8 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
using Compression.Registry;
2+
using FileSystem.Refs;
3+
4+
namespace Compression.Tests.Refs;
5+
6+
[TestFixture]
7+
public sealed class RefsAmbiguousNamespaceTests {
8+
[Test, Category("ErrorHandling")]
9+
public void WritableResolver_RejectsCaseFoldedDuplicateNames() {
10+
var image = new RefsSyntheticVolume()
11+
.WithFile("alpha.bin", [1, 2, 3])
12+
.WithFile("ALPHA.bin", [4, 5, 6])
13+
.Build();
14+
using var stream = new MemoryStream(image, writable: true);
15+
16+
var resolver = new RefsWritableNamespace(RefsMetadataReader.Open(stream));
17+
Assert.That(() => resolver.ResolveDirectoryEntry("alpha.bin"),
18+
Throws.TypeOf<NotSupportedException>().With.Message.Contains("ambiguous"));
19+
}
20+
21+
[Test, Category("ErrorHandling")]
22+
public void OfflineMutations_RejectAmbiguousPathBeforeWriting() {
23+
var image = new RefsSyntheticVolume()
24+
.WithFile("alpha.bin", [1, 2, 3])
25+
.WithFile("ALPHA.bin", [4, 5, 6])
26+
.WithFile("destination.bin", [7, 8, 9])
27+
.Build();
28+
var original = image.ToArray();
29+
using var stream = new MemoryStream(image, writable: true);
30+
31+
// Add also refuses a missing path with NotSupportedException, so the
32+
// message is what proves the ambiguity check fired rather than that one.
33+
static NUnit.Framework.Constraints.IResolveConstraint Ambiguous() => Throws.TypeOf<NotSupportedException>().With.Message.Contains("ambiguous");
34+
Assert.Multiple(() => {
35+
Assert.That(() => RefsOfflineModifier.Add(
36+
stream, [ArchiveInputInfo.InMemory("alpha.bin", [9, 9, 9])]), Ambiguous());
37+
Assert.That(() => RefsOfflineModifier.Remove(stream, ["alpha.bin"]), Ambiguous());
38+
Assert.That(() => RefsOfflineBlockCloner.CloneWholeFile(
39+
stream, "alpha.bin", "destination.bin"), Ambiguous());
40+
Assert.That(() => RefsOfflineBlockCloner.CloneWholeFile(
41+
stream, "destination.bin", "ALPHA.BIN"), Ambiguous());
42+
Assert.That(image, Is.EqualTo(original));
43+
});
44+
}
45+
46+
[Test, Category("HappyPath")]
47+
public void UniqueCaseFoldedPath_StillResolvesToTheOnlyMatch() {
48+
var image = new RefsSyntheticVolume()
49+
.WithFile("alpha.bin", [1, 2, 3])
50+
.WithFile("beta.bin", [4, 5, 6])
51+
.Build();
52+
using var stream = new MemoryStream(image, writable: true);
53+
54+
var location = new RefsWritableNamespace(RefsMetadataReader.Open(stream)).ResolveDirectoryEntry("ALPHA.BIN");
55+
56+
Assert.That(location.EntryRow.Key.AsSpan(4).ToArray(), Is.EqualTo(System.Text.Encoding.Unicode.GetBytes("alpha.bin")));
57+
}
58+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
#pragma warning disable CS1591
2+
3+
namespace FileSystem.Refs;
4+
5+
/// <summary>
6+
/// A writable path must identify exactly one namespace row. ReFS directories
7+
/// can contain names that differ only by case, while the offline API resolves
8+
/// paths case-insensitively; choosing either row would silently edit the wrong
9+
/// file when the caller did not supply case-sensitive lookup semantics.
10+
/// </summary>
11+
internal static class RefsNamespaceLookup {
12+
public static RefsFileRecord? FindUnique(IEnumerable<RefsFileRecord> files, string path) {
13+
RefsFileRecord? match = null;
14+
foreach (var file in files) {
15+
if (!string.Equals(file.Path, path, StringComparison.OrdinalIgnoreCase)) continue;
16+
if (match != null)
17+
throw new NotSupportedException($"ReFS path '{path}' is ambiguous under case-insensitive lookup.");
18+
match = file;
19+
}
20+
return match;
21+
}
22+
}

‎Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Refs/RefsOfflineBlockCloner.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ private static RefsFileRecord FindRegularFile(
246246
IEnumerable<RefsFileRecord> files,
247247
string path,
248248
string parameterName) {
249-
var file = files.FirstOrDefault(item => string.Equals(item.Path, path, StringComparison.OrdinalIgnoreCase))
249+
var file = RefsNamespaceLookup.FindUnique(files, path)
250250
?? throw new FileNotFoundException($"ReFS file '{path}' was not found.", path);
251251
if (file.IsDirectory)
252252
throw new ArgumentException($"ReFS path '{path}' names a directory.", parameterName);

‎Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Refs/RefsOfflineModifier.cs‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,8 @@ public static void Add(Stream image, IReadOnlyList<ArchiveInputInfo> inputs) {
4040
throw new ArgumentException("ReFS entry path must not be empty.", nameof(inputs));
4141

4242
var metadata = RefsMetadataReader.Open(image);
43-
var existing = new RefsNamespaceReader(metadata).ReadAll().FirstOrDefault(f =>
44-
!f.IsDirectory && string.Equals(f.Path, path, StringComparison.OrdinalIgnoreCase));
45-
if (existing == null)
43+
var existing = RefsNamespaceLookup.FindUnique(new RefsNamespaceReader(metadata).ReadAll(), path);
44+
if (existing == null || existing.IsDirectory)
4645
throw new NotSupportedException(
4746
$"ReFS offline R/W currently replaces existing regular files; creating the new namespace entry '{path}' " +
4847
"is withheld until all file-identity/security/link fields are proven for the active ReFS profile.");
@@ -161,9 +160,10 @@ private static void ValidateFilename(string name) {
161160
private static void ReplaceExisting(Stream image, string path, byte[] data) {
162161
var metadata = RefsMetadataReader.Open(image);
163162
var files = new RefsNamespaceReader(metadata).ReadAll();
164-
var file = files.FirstOrDefault(f =>
165-
!f.IsDirectory && string.Equals(f.Path, path, StringComparison.OrdinalIgnoreCase))
163+
var file = RefsNamespaceLookup.FindUnique(files, path)
166164
?? throw new FileNotFoundException($"ReFS file '{path}' is no longer reachable.", path);
165+
if (file.IsDirectory)
166+
throw new InvalidOperationException($"ReFS path '{path}' names a directory, not a writable file.");
167167
if (file.Extents.Any(e => e.IsSparse || e.Flags == 0x1C00D0 || (e.Flags & 0x04) != 0))
168168
throw new NotSupportedException(
169169
$"ReFS file '{path}' uses sparse/integrity/shared allocation semantics outside the offline CRUD profile.");
@@ -250,7 +250,7 @@ private static void ReplaceExisting(Stream image, string path, byte[] data) {
250250
private static void RemoveOne(Stream image, string path) {
251251
var metadata = RefsMetadataReader.Open(image);
252252
var files = new RefsNamespaceReader(metadata).ReadAll();
253-
var file = files.FirstOrDefault(f => string.Equals(f.Path, path, StringComparison.OrdinalIgnoreCase))
253+
var file = RefsNamespaceLookup.FindUnique(files, path)
254254
?? throw new FileNotFoundException($"ReFS entry '{path}' was not found.", path);
255255

256256
if (file.IsDirectory && files.Any(f =>

‎Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Refs/RefsWritableNamespace.cs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,9 @@ public RefsWritableEntryLocation ResolveDirectoryEntry(string path) {
5555
if (row.Key.Length < 4 || BinaryPrimitives.ReadUInt16LittleEndian(row.Key) != 0x30) continue;
5656
var name = DecodeName(row.Key.AsSpan(4));
5757
if (name.Equals(parts[partIndex], StringComparison.OrdinalIgnoreCase)) {
58+
if (match != null)
59+
throw new NotSupportedException($"ReFS path '{path}' is ambiguous at '{parts[partIndex]}' under case-insensitive lookup.");
5860
match = row;
59-
break;
6061
}
6162
}
6263
if (match == null)

0 commit comments

Comments
 (0)