CultCache C#: a dangling Windows junction at the store path is an IOException, and the tests reach it without a privilege

Opening a junction whose target is gone fails with UnauthorizedAccessException, not the
IOException the dangling-link rule promises. ReadDisk now turns that into the same
IOException. The dangling-link tests build a directory junction (mklink /J), which needs
no privilege, and the file-symlink cases skip where the account cannot create one.
The fixture removes links before its recursive delete.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-02 23:56:53 +02:00
commit 42f05f6681
2 changed files with 87 additions and 8 deletions

View file

@ -4023,6 +4023,11 @@ namespace GameCult.Caching
{
throw new IOException("The store path is a link whose target cannot be reached.", ex);
}
// A Windows junction is a directory, not a file, so opening one that dangles fails with an access error, not a missing file.
catch (UnauthorizedAccessException ex) when (PathIsALink() && (FileInfo.Attributes & FileAttributes.Directory) != 0)
{
throw new IOException("The store path is a link whose target cannot be reached.", ex);
}
catch (FileNotFoundException)
{
return null;

View file

@ -1,6 +1,7 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using GameCult.Caching.MessagePack;
@ -28,8 +29,18 @@ namespace GameCult.Caching.Tests
[TearDown]
public void TearDown()
{
if (Directory.Exists(_directory))
Directory.Delete(_directory, true);
if (!Directory.Exists(_directory))
return;
// A recursive delete cannot remove a link whose target is gone; take the links out by themselves first.
foreach (var entry in Directory.EnumerateFileSystemEntries(_directory).Where(entry => IsALink(entry)))
{
if ((new FileInfo(entry).Attributes & FileAttributes.Directory) != 0)
Directory.Delete(entry, false);
else
File.Delete(entry);
}
Directory.Delete(_directory, true);
}
private static string VectorRoot()
@ -169,12 +180,35 @@ namespace GameCult.Caching.Tests
}
// Only nothing at the path is an empty store. A link whose target is gone is an I/O error on open and on commit, never an
// empty store and never an untyped missing-file failure, and the link is left where it was.
// empty store and never an untyped missing-file or access failure, and the link is left where it was.
[Test]
public void ADanglingLinkIsAnIoErrorOnOpenAndOnCommitNotAnEmptyStore()
public void ADanglingDirectoryLinkIsAnIoErrorOnOpenNotAnEmptyStore()
{
var path = Path.Combine(_directory, "dangling.cc");
File.CreateSymbolicLink(path, Path.Combine(_directory, "unmounted", "store.cc"));
CreateDanglingDirectoryLink(path, Path.Combine(_directory, "unmounted"));
Assert.That(() => CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }), Throws.TypeOf<IOException>());
Assert.That(IsALink(path), "the link was replaced");
Assert.That(Directory.Exists(Path.Combine(_directory, "unmounted")), Is.False);
}
[Test]
public void ACommitOntoADanglingDirectoryLinkIsAnIoErrorAndLeavesTheLink()
{
var path = Path.Combine(_directory, "link.cc");
using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry });
CreateDanglingDirectoryLink(path, Path.Combine(_directory, "unmounted"));
Assert.That(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))), Throws.TypeOf<IOException>());
Assert.That(IsALink(path), "the link was replaced");
Assert.That(Directory.Exists(Path.Combine(_directory, "unmounted")), Is.False, "a write went through the link");
}
[Test]
public void ADanglingFileLinkIsAnIoErrorOnOpenNotAnEmptyStore()
{
var path = Path.Combine(_directory, "dangling-file.cc");
CreateFileLinkOrIgnore(path, Path.Combine(_directory, "unmounted", "store.cc"));
Assert.That(() => CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }), Throws.TypeOf<IOException>());
Assert.That(new FileInfo(path).LinkTarget, Is.Not.Null, "the link was replaced");
@ -182,12 +216,12 @@ namespace GameCult.Caching.Tests
}
[Test]
public void ACommitOntoALinkThatDanglesAfterOpenIsAnIoErrorAndLeavesTheLink()
public void ACommitOntoAFileLinkThatDanglesAfterOpenIsAnIoErrorAndLeavesTheLink()
{
var target = Path.Combine(_directory, "target.cc");
var path = Path.Combine(_directory, "link.cc");
var path = Path.Combine(_directory, "file-link.cc");
File.WriteAllBytes(target, File.ReadAllBytes(Path.Combine(VectorRoot(), "empty-array.bin")));
File.CreateSymbolicLink(path, target);
CreateFileLinkOrIgnore(path, target);
using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry });
File.Delete(target);
@ -196,6 +230,46 @@ namespace GameCult.Caching.Tests
Assert.That(File.Exists(target), Is.False, "a write went through the link");
}
// A link whose target is gone. A directory junction needs no privilege on Windows, where a file symlink needs one this
// workstation lacks; elsewhere a symlink to a missing directory is the same thing.
private static void CreateDanglingDirectoryLink(string path, string target)
{
if (!OperatingSystem.IsWindows())
{
File.CreateSymbolicLink(path, target);
return;
}
Directory.CreateDirectory(target);
var mklink = Process.Start(new ProcessStartInfo("cmd.exe", $"/c mklink /J \"{path}\" \"{target}\"")
{
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
})!;
mklink.WaitForExit();
Assert.That(mklink.ExitCode, Is.Zero, "mklink /J failed");
Directory.Delete(target);
}
private static bool IsALink(string path)
{
var attributes = new FileInfo(path).Attributes;
return (int)attributes != -1 && (attributes & FileAttributes.ReparsePoint) != 0;
}
private static void CreateFileLinkOrIgnore(string path, string target)
{
try
{
File.CreateSymbolicLink(path, target);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Assert.Ignore("A file symlink needs a privilege this account lacks: " + ex.Message);
}
}
// An id one entry owns and a later entry lists as compatible names the entry that owns it.
[TestCase("own-id-over-compatible-v3.bin")]
[TestCase("compatible-before-owner-v3.bin")]