From 598dcf1cdc7c8653927a00993367f6038ad91454 Mon Sep 17 00:00:00 2001 From: NeuralFault Date: Wed, 16 Sep 2026 23:55:51 +0000 Subject: [PATCH] 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() {