From 598dcf1cdc7c8653927a00993367f6038ad91454 Mon Sep 17 00:00:00 2001 From: NeuralFault Date: Wed, 16 Sep 2026 23:55:51 +0000 Subject: [PATCH 1/2] fix(LinkSafeFileSystem): stop links from shadowing real folders in EnumerateFiles - Key real directories ordinally and claim their identity on pop, not push, so case-distinct folders are both scanned and scan order decides the winner - Drain real directories before following any links so a link (e.g. `diffusion_models` -> `DiffusionModels`) can never take the visited key and drop the real folder's subtree - Compare link-resolved targets with platform case sensitivity (PathComparer) against real dirs and other links to keep the loop guards intact - Log skipped links at Info and skipped real directories at Warn, naming the earlier scanned path - Add tests for sibling and deeper shadow links, plus a Windows-only junction target case-mismatch test --- .../Helper/LinkSafeFileSystem.cs | 85 +++++++++++++++---- .../Helper/LinkSafeFileSystemTests.cs | 56 ++++++++++++ 2 files changed, 123 insertions(+), 18 deletions(-) diff --git a/StabilityMatrix.Core/Helper/LinkSafeFileSystem.cs b/StabilityMatrix.Core/Helper/LinkSafeFileSystem.cs index 3260e0d6e..0e06a4b20 100644 --- a/StabilityMatrix.Core/Helper/LinkSafeFileSystem.cs +++ b/StabilityMatrix.Core/Helper/LinkSafeFileSystem.cs @@ -98,10 +98,13 @@ public static bool WouldLinkCycle(DirectoryPath sourceDir, DirectoryPath linkPat /// /// Recursively enumerates files matching under - /// . Linked directories are followed once; a link back to a directory - /// already visited is skipped, as are directories deeper than . - /// Inaccessible directories are skipped rather than aborting the enumeration. - /// Yielded paths are rooted at as given, not at its resolved target. + /// . Linked directories are followed once; a link whose target has + /// already been scanned is skipped and logged at Info naming the earlier path. Real directories + /// are scanned before links so a link cannot shadow a real folder; a real directory that would + /// be scanned twice is skipped and logged at Warn. Directories deeper than + /// are skipped, as are inaccessible directories, which are skipped + /// rather than aborting the enumeration. Yielded paths are rooted at + /// as given, not at its resolved target. /// public static IEnumerable EnumerateFiles( string rootDir, @@ -109,15 +112,61 @@ public static IEnumerable EnumerateFiles( int maxDepth = DefaultMaxDepth ) { - var visited = new HashSet(PathComparer); - var pending = new Stack<(string Path, string RealPath, int Depth)>(); + // A real directory is keyed by its literal path, compared ordinally, so two folders whose + // names differ only in case are both scanned. A link is keyed by its resolved target, + // compared with the platform's case sensitivity (PathComparer), because a target is stored + // however the link was created. + var visitedRealDirsExact = new Dictionary(StringComparer.Ordinal); + var visitedRealDirsForLinkTargets = new Dictionary(PathComparer); + var visitedLinkTargets = new Dictionary(PathComparer); + + // Real directories are drained to completion before any link is considered, so a link can + // never take the identity of a real folder and shadow it out of the scan. + var realDirs = new Stack<(string Path, string RealPath, int Depth, bool IsLink)>(); + var linkedDirs = new Stack<(string Path, string RealPath, int Depth, bool IsLink)>(); var rootReal = GetRealPath(rootDir); - visited.Add(rootReal); - pending.Push((rootDir, rootReal, 0)); + realDirs.Push((rootDir, rootReal, 0, false)); - while (pending.TryPop(out var dir)) + while (realDirs.Count > 0 || linkedDirs.Count > 0) { + var dir = realDirs.Count > 0 ? realDirs.Pop() : linkedDirs.Pop(); + + // Claimed on pop, not on push, so the walk order decides which spelling owns the + // identity instead of the reversed push order. + if (dir.IsLink) + { + if ( + visitedLinkTargets.TryGetValue(dir.RealPath, out var linkClaimer) + || visitedRealDirsForLinkTargets.TryGetValue(dir.RealPath, out linkClaimer) + ) + { + Logger.Info( + "Skipping {Path}: the same directory was already scanned as {ClaimedBy}", + dir.Path, + linkClaimer + ); + continue; + } + + visitedLinkTargets[dir.RealPath] = dir.Path; + } + else + { + if (visitedRealDirsExact.TryGetValue(dir.RealPath, out var realClaimer)) + { + Logger.Warn( + "Skipping {Path}: the same directory was already scanned as {ClaimedBy}", + dir.Path, + realClaimer + ); + continue; + } + + visitedRealDirsExact[dir.RealPath] = dir.Path; + visitedRealDirsForLinkTargets[dir.RealPath] = dir.Path; + } + List files; List subDirs; try @@ -150,21 +199,21 @@ public static IEnumerable EnumerateFiles( continue; } - // Pushed in reverse so the stack pops them in enumeration order + // Pushed in reverse so each stack pops its entries in enumeration order for (var i = subDirs.Count - 1; i >= 0; i--) { var subDir = subDirs[i]; - var subReal = subDir.Attributes.HasFlag(FileAttributes.ReparsePoint) - ? GetRealPath(subDir.FullName) - : Path.Join(dir.RealPath, subDir.Name); + var isLinkDir = subDir.Attributes.HasFlag(FileAttributes.ReparsePoint); + var subReal = isLinkDir ? GetRealPath(subDir.FullName) : Path.Join(dir.RealPath, subDir.Name); - if (!visited.Add(subReal)) + if (isLinkDir) { - Logger.Debug("Skipping {Path}: already visited as {RealPath}", subDir.FullName, subReal); - continue; + linkedDirs.Push((subDir.FullName, subReal, dir.Depth + 1, true)); + } + else + { + realDirs.Push((subDir.FullName, subReal, dir.Depth + 1, false)); } - - pending.Push((subDir.FullName, subReal, dir.Depth + 1)); } } } diff --git a/StabilityMatrix.Tests/Helper/LinkSafeFileSystemTests.cs b/StabilityMatrix.Tests/Helper/LinkSafeFileSystemTests.cs index c24ed447d..e5705a5b0 100644 --- a/StabilityMatrix.Tests/Helper/LinkSafeFileSystemTests.cs +++ b/StabilityMatrix.Tests/Helper/LinkSafeFileSystemTests.cs @@ -100,6 +100,62 @@ public void EnumerateFiles_TwoLinksToSameDirectory_VisitsItOnce() Assert.AreEqual(1, files.Count); } + [DataTestMethod] + [DataRow("diffusion_models")] + [DataRow("sub", "alias")] + public void EnumerateFiles_RealFolderShadowedByLink_KeepsRealFolderPaths(params string[] linkSegments) + { + var root = CreateDir("root"); + CreateFile("root", "DiffusionModels", "a.json"); + CreateFile("root", "DiffusionModels", "b.json"); + + var linkPath = Path.Combine([root, .. linkSegments]); + Directory.CreateDirectory(Path.GetDirectoryName(linkPath)!); + TempFiles.CreateDirectoryLink(linkPath, Path.Combine(root, "DiffusionModels")); + + var files = LinkSafeFileSystem.EnumerateFiles(root, "*.json").ToList(); + + CollectionAssert.AreEquivalent( + new[] + { + Path.Combine(root, "DiffusionModels", "a.json"), + Path.Combine(root, "DiffusionModels", "b.json"), + }, + files + ); + } + + [TestMethod] + public void EnumerateFiles_JunctionTargetCaseMismatch_KeepsRealFolderPaths() + { + if (!Compat.IsWindows) + { + Assert.Inconclusive("Junctions with a differently-cased stored target are Windows-only."); + return; + } + + var root = CreateDir("root"); + CreateFile("root", "DiffusionModels", "a.json"); + CreateFile("root", "DiffusionModels", "b.json"); + + // Store the junction target with different casing than the real folder on disk. + TempFiles.CreateDirectoryLink( + Path.Combine(root, "diffusion_models"), + Path.Combine(root.ToUpperInvariant(), "DIFFUSIONMODELS") + ); + + var files = LinkSafeFileSystem.EnumerateFiles(root, "*.json").ToList(); + + CollectionAssert.AreEquivalent( + new[] + { + Path.Combine(root, "DiffusionModels", "a.json"), + Path.Combine(root, "DiffusionModels", "b.json"), + }, + files + ); + } + [TestMethod] public void EnumerateFiles_DeeperThanMaxDepth_IsSkipped() { From 8525cdf4a6975a0627ec7cab0465118dd9d1ceaf Mon Sep 17 00:00:00 2001 From: NeuralFault Date: Sat, 26 Sep 2026 00:06:33 +0000 Subject: [PATCH 2/2] fix(LinkSafeFileSystem): stop rescanning real folders claimed as link targets A real directory reached through a link was only checked against the real directory keys, so a link that had already claimed the same physical directory did not dedupe it, and the files were yielded twice. - Check visitedLinkTargets in the real directory branch as well - Drop the redundant IsLink stack field and trim the EnumerateFiles summary - Add a depth-based regression test that pins walk order portably, plus a Windows-only test covering the reported sibling-link repro --- .../Helper/LinkSafeFileSystem.cs | 40 +++++++----- .../Helper/LinkSafeFileSystemTests.cs | 65 +++++++++++++++++++ 2 files changed, 87 insertions(+), 18 deletions(-) diff --git a/StabilityMatrix.Core/Helper/LinkSafeFileSystem.cs b/StabilityMatrix.Core/Helper/LinkSafeFileSystem.cs index 0e06a4b20..3528ca62a 100644 --- a/StabilityMatrix.Core/Helper/LinkSafeFileSystem.cs +++ b/StabilityMatrix.Core/Helper/LinkSafeFileSystem.cs @@ -98,13 +98,11 @@ public static bool WouldLinkCycle(DirectoryPath sourceDir, DirectoryPath linkPat /// /// Recursively enumerates files matching under - /// . Linked directories are followed once; a link whose target has - /// already been scanned is skipped and logged at Info naming the earlier path. Real directories - /// are scanned before links so a link cannot shadow a real folder; a real directory that would - /// be scanned twice is skipped and logged at Warn. Directories deeper than - /// are skipped, as are inaccessible directories, which are skipped - /// rather than aborting the enumeration. Yielded paths are rooted at - /// as given, not at its resolved target. + /// . Symbolic links are followed, but every physical directory is + /// visited at most once, so no file is yielded twice. Directories nested deeper than + /// and directories that cannot be read are skipped without aborting + /// the enumeration. Yielded paths are rooted at as given, not at its + /// resolved target. /// public static IEnumerable EnumerateFiles( string rootDir, @@ -113,28 +111,31 @@ public static IEnumerable EnumerateFiles( ) { // A real directory is keyed by its literal path, compared ordinally, so two folders whose - // names differ only in case are both scanned. A link is keyed by its resolved target, - // compared with the platform's case sensitivity (PathComparer), because a target is stored - // however the link was created. + // names differ only in case are both scanned; it is also matched against link targets, so a + // real folder reached through a link is not rescanned. A link is keyed by its resolved + // target, compared with the platform's case sensitivity (PathComparer), because a target is + // stored however the link was created. var visitedRealDirsExact = new Dictionary(StringComparer.Ordinal); var visitedRealDirsForLinkTargets = new Dictionary(PathComparer); var visitedLinkTargets = new Dictionary(PathComparer); // Real directories are drained to completion before any link is considered, so a link can // never take the identity of a real folder and shadow it out of the scan. - var realDirs = new Stack<(string Path, string RealPath, int Depth, bool IsLink)>(); - var linkedDirs = new Stack<(string Path, string RealPath, int Depth, bool IsLink)>(); + var realDirs = new Stack<(string Path, string RealPath, int Depth)>(); + var linkedDirs = new Stack<(string Path, string RealPath, int Depth)>(); var rootReal = GetRealPath(rootDir); - realDirs.Push((rootDir, rootReal, 0, false)); + realDirs.Push((rootDir, rootReal, 0)); while (realDirs.Count > 0 || linkedDirs.Count > 0) { - var dir = realDirs.Count > 0 ? realDirs.Pop() : linkedDirs.Pop(); + // Which stack the entry came from is how the walk knows whether it is a link. + var fromRealDirs = realDirs.Count > 0; + var dir = fromRealDirs ? realDirs.Pop() : linkedDirs.Pop(); // Claimed on pop, not on push, so the walk order decides which spelling owns the // identity instead of the reversed push order. - if (dir.IsLink) + if (!fromRealDirs) { if ( visitedLinkTargets.TryGetValue(dir.RealPath, out var linkClaimer) @@ -153,7 +154,10 @@ public static IEnumerable EnumerateFiles( } else { - if (visitedRealDirsExact.TryGetValue(dir.RealPath, out var realClaimer)) + if ( + visitedRealDirsExact.TryGetValue(dir.RealPath, out var realClaimer) + || visitedLinkTargets.TryGetValue(dir.RealPath, out realClaimer) + ) { Logger.Warn( "Skipping {Path}: the same directory was already scanned as {ClaimedBy}", @@ -208,11 +212,11 @@ public static IEnumerable EnumerateFiles( if (isLinkDir) { - linkedDirs.Push((subDir.FullName, subReal, dir.Depth + 1, true)); + linkedDirs.Push((subDir.FullName, subReal, dir.Depth + 1)); } else { - realDirs.Push((subDir.FullName, subReal, dir.Depth + 1, false)); + realDirs.Push((subDir.FullName, subReal, dir.Depth + 1)); } } } diff --git a/StabilityMatrix.Tests/Helper/LinkSafeFileSystemTests.cs b/StabilityMatrix.Tests/Helper/LinkSafeFileSystemTests.cs index e5705a5b0..2f86c72ca 100644 --- a/StabilityMatrix.Tests/Helper/LinkSafeFileSystemTests.cs +++ b/StabilityMatrix.Tests/Helper/LinkSafeFileSystemTests.cs @@ -100,6 +100,71 @@ public void EnumerateFiles_TwoLinksToSameDirectory_VisitsItOnce() Assert.AreEqual(1, files.Count); } + [DataTestMethod] + [DataRow(true)] + [DataRow(false)] + public void EnumerateFiles_RealDirAlreadyClaimedAsLinkTarget_IsVisitedOnce(bool xyLinkIsDeeper) + { + // ext/X/Y/y.json is reachable two ways: through a link to ext/X/Y, and as a real subfolder + // of a link to ext/X. Sibling enumeration order is file-system dependent, so nesting the + // links at different depths pins the walk order instead of relying on names: the deeper + // link is always popped first (draining real dirs first pushes it last, and the link stack + // pops last-in first-out). + var root = CreateDir("root"); + var sub = CreateDir("root", "sub"); + CreateFile("ext", "X", "Y", "y.json"); + + var x = Path.Combine(tempDir, "ext", "X"); + var xy = Path.Combine(x, "Y"); + + // Deep link -> ext/X/Y, shallow link -> ext/X. When the deep link is the one targeting + // ext/X/Y, it is walked first and the shallower ext/X link then reaches that same real + // folder again through its "Y" child. + var xyLink = Path.Combine(xyLinkIsDeeper ? sub : root, "inner"); + var xLink = Path.Combine(xyLinkIsDeeper ? root : sub, "outer"); + TempFiles.CreateDirectoryLink(xyLink, xy); + TempFiles.CreateDirectoryLink(xLink, x); + + var files = LinkSafeFileSystem.EnumerateFiles(root, "*.json").ToList(); + + Assert.AreEqual(1, files.Count, $"Expected one file, got: {string.Join(", ", files)}"); + } + + [DataTestMethod] + [DataRow("a_inner", "b_outer")] + [DataRow("b_inner", "a_outer")] + public void EnumerateFiles_NestedLinkTarget_SiblingLinkOrder_IsVisitedOnce( + string innerName, + string outerName + ) + { + if (!Compat.IsWindows) + { + Assert.Inconclusive( + "Needs NTFS, which enumerates sibling directories in stored name order; " + + "EnumerateFiles_RealDirAlreadyClaimedAsLinkTarget_IsVisitedOnce covers the same " + + "bug portably by varying depth instead." + ); + return; + } + + // The maintainer's original repro: innerName -> ext/X/Y, outerName -> ext/X, so the inner + // link's target is also reached as a real subfolder of the outer link. NTFS yields siblings + // in name order, so the two rows walk the links in opposite orders. + var root = CreateDir("root"); + CreateFile("ext", "X", "Y", "y.json"); + + var x = Path.Combine(tempDir, "ext", "X"); + var xy = Path.Combine(x, "Y"); + + TempFiles.CreateDirectoryLink(Path.Combine(root, innerName), xy); + TempFiles.CreateDirectoryLink(Path.Combine(root, outerName), x); + + var files = LinkSafeFileSystem.EnumerateFiles(root, "*.json").ToList(); + + Assert.AreEqual(1, files.Count, $"Expected one file, got: {string.Join(", ", files)}"); + } + [DataTestMethod] [DataRow("diffusion_models")] [DataRow("sub", "alias")]