Skip to content
Open
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
85 changes: 67 additions & 18 deletions StabilityMatrix.Core/Helper/LinkSafeFileSystem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,26 +98,75 @@ public static bool WouldLinkCycle(DirectoryPath sourceDir, DirectoryPath linkPat

/// <summary>
/// Recursively enumerates files matching <paramref name="searchPattern"/> under
/// <paramref name="rootDir"/>. Linked directories are followed once; a link back to a directory
/// already visited is skipped, as are directories deeper than <paramref name="maxDepth"/>.
/// Inaccessible directories are skipped rather than aborting the enumeration.
/// Yielded paths are rooted at <paramref name="rootDir"/> as given, not at its resolved target.
/// <paramref name="rootDir"/>. 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
/// <paramref name="maxDepth"/> are skipped, as are inaccessible directories, which are skipped
/// rather than aborting the enumeration. Yielded paths are rooted at <paramref name="rootDir"/>
/// as given, not at its resolved target.
/// </summary>
public static IEnumerable<string> EnumerateFiles(
string rootDir,
string searchPattern,
int maxDepth = DefaultMaxDepth
)
{
var visited = new HashSet<string>(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<string, string>(StringComparer.Ordinal);
var visitedRealDirsForLinkTargets = new Dictionary<string, string>(PathComparer);
var visitedLinkTargets = new Dictionary<string, string>(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<string> files;
List<DirectoryInfo> subDirs;
try
Expand Down Expand Up @@ -150,21 +199,21 @@ public static IEnumerable<string> 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));
}
}
}
Expand Down
56 changes: 56 additions & 0 deletions StabilityMatrix.Tests/Helper/LinkSafeFileSystemTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading