mirror of
https://github.com/SabreTools/SabreTools.IO.git
synced 2026-11-15 16:41:58 +00:00
Do not follow linked directories in SafeGetFiles outside of Windows (#8)
* Do not follow linked directories in SafeGetFiles outside of Windows SafeGetFiles with SearchOption.AllDirectories follows directory symlinks. On Linux, the UDF view of some PC/Mac hybrid discs has symlinks whose target reads as empty, and each one resolves to its own folder, so the recursion never ends (fixes #2, SabreTools/BinaryObjectScanner#276). Outside of Windows, walk the tree one directory at a time and do not enter directories that carry FileAttributes.ReparsePoint. Files are matched with Directory.GetFiles per directory, so the pattern behaves as before, and the order is the same breadth-first order. Windows keeps Directory.GetFiles. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Do not follow linked directories in the other AllDirectories overloads SafeGetDirectories and the SafeEnumerateDirectories, SafeEnumerateFiles and SafeEnumerateFileSystemEntries wrappers follow directory symlinks in the same way. On Linux, with one directory linked to itself, SafeEnumerateFiles returns 43 files instead of 2, and with three such links it is still running after 10 seconds. The walk from SafeGetFiles is now a lazy iterator that all of them share, so the Enumerate wrappers stay lazy. Each directory is listed with the matching overload without SearchOption (Directory.GetFiles, SafeEnumerateFiles and so on), so patterns and inaccessible directories behave as in the recursive search. If the attributes of a subdirectory cannot be read, such as below a directory without execute permission, it is entered anyway, so it fails in the same way as in the recursive search. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Check for linked directories without catching exceptions DirectoryInfo.Exists returns false where the attributes cannot be read, and the criteria for the walk are checked one at a time with their reasons. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -510,6 +510,25 @@ namespace SabreTools.IO.Extensions.Test
|
||||
Assert.Single(dirs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SafeGetDirectories_LinkedDirectory_NotFollowed()
|
||||
{
|
||||
// Linked directories are only skipped outside of Windows
|
||||
if (SystemEnvironment.IsWindows())
|
||||
return;
|
||||
|
||||
string root = CreateLinkedDirectoryTree();
|
||||
try
|
||||
{
|
||||
var dirs = root.SafeGetDirectories("*", SearchOption.AllDirectories);
|
||||
Assert.Equal(2, dirs.Length);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SafeGetFiles
|
||||
@@ -538,6 +557,41 @@ namespace SabreTools.IO.Extensions.Test
|
||||
Assert.NotEmpty(files);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SafeGetFiles_ValidDirectory_PatternTopDirectoryOnly()
|
||||
{
|
||||
string dir = Path.Combine(Environment.CurrentDirectory, "TestData", "Path");
|
||||
var files = dir.SafeGetFiles("*", SearchOption.TopDirectoryOnly);
|
||||
Assert.Equal(11, files.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SafeGetFiles_ValidDirectory_PatternWithDirectory()
|
||||
{
|
||||
string dir = Path.Combine(Environment.CurrentDirectory, "TestData");
|
||||
var files = dir.SafeGetFiles(Path.Combine("Path", "Subdirectory", "*"), SearchOption.AllDirectories);
|
||||
Assert.Single(files);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SafeGetFiles_LinkedDirectory_NotFollowed()
|
||||
{
|
||||
// Linked directories are only skipped outside of Windows
|
||||
if (SystemEnvironment.IsWindows())
|
||||
return;
|
||||
|
||||
string root = CreateLinkedDirectoryTree();
|
||||
try
|
||||
{
|
||||
var files = root.SafeGetFiles("*", SearchOption.AllDirectories);
|
||||
Assert.Equal(3, files.Length);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SafeGetFileSystemEntries
|
||||
@@ -594,6 +648,25 @@ namespace SabreTools.IO.Extensions.Test
|
||||
Assert.Single(dirs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SafeEnumerateDirectories_LinkedDirectory_NotFollowed()
|
||||
{
|
||||
// Linked directories are only skipped outside of Windows
|
||||
if (SystemEnvironment.IsWindows())
|
||||
return;
|
||||
|
||||
string root = CreateLinkedDirectoryTree();
|
||||
try
|
||||
{
|
||||
List<string> dirs = [.. root.SafeEnumerateDirectories("*", SearchOption.AllDirectories)];
|
||||
Assert.Equal(2, dirs.Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SafeEnumerateFiles
|
||||
@@ -622,6 +695,53 @@ namespace SabreTools.IO.Extensions.Test
|
||||
Assert.NotEmpty(files);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SafeEnumerateFiles_LinkedDirectory_NotFollowed()
|
||||
{
|
||||
// Linked directories are only skipped outside of Windows
|
||||
if (SystemEnvironment.IsWindows())
|
||||
return;
|
||||
|
||||
string root = CreateLinkedDirectoryTree();
|
||||
try
|
||||
{
|
||||
List<string> files = [.. root.SafeEnumerateFiles("*", SearchOption.AllDirectories)];
|
||||
Assert.Equal(3, files.Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SafeEnumerateFiles_DirectoryWithoutExecutePermission_Skipped()
|
||||
{
|
||||
// Execute permission on directories only exists outside of Windows
|
||||
if (OperatingSystem.IsWindows())
|
||||
return;
|
||||
|
||||
string root = Path.Combine(Environment.CurrentDirectory, Guid.NewGuid().ToString());
|
||||
string locked = Path.Combine(root, "Locked");
|
||||
string other = Path.Combine(root, "Other", "Subdirectory");
|
||||
Directory.CreateDirectory(Path.Combine(locked, "Subdirectory"));
|
||||
Directory.CreateDirectory(other);
|
||||
File.WriteAllBytes(Path.Combine(locked, "file.bin"), []);
|
||||
File.WriteAllBytes(Path.Combine(other, "file.bin"), []);
|
||||
File.SetUnixFileMode(locked, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
try
|
||||
{
|
||||
// Only the subdirectory of the locked directory is left out
|
||||
List<string> files = [.. root.SafeEnumerateFiles("*", SearchOption.AllDirectories)];
|
||||
Assert.Equal(2, files.Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.SetUnixFileMode(locked, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SafeEnumerateFileSystemEntries
|
||||
@@ -650,6 +770,41 @@ namespace SabreTools.IO.Extensions.Test
|
||||
Assert.NotEmpty(entries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SafeEnumerateFileSystemEntries_LinkedDirectory_NotFollowed()
|
||||
{
|
||||
// Linked directories are only skipped outside of Windows
|
||||
if (SystemEnvironment.IsWindows())
|
||||
return;
|
||||
|
||||
string root = CreateLinkedDirectoryTree();
|
||||
try
|
||||
{
|
||||
List<string> entries = [.. root.SafeEnumerateFileSystemEntries("*", SearchOption.AllDirectories)];
|
||||
Assert.Equal(5, entries.Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Create a directory tree with a linked file and a directory linked to the directory it is in
|
||||
/// </summary>
|
||||
/// <returns>Root of the tree, to be deleted by the caller</returns>
|
||||
private static string CreateLinkedDirectoryTree()
|
||||
{
|
||||
string root = Path.Combine(Environment.CurrentDirectory, Guid.NewGuid().ToString());
|
||||
string subdir = Path.Combine(root, "Subdirectory");
|
||||
Directory.CreateDirectory(subdir);
|
||||
File.WriteAllBytes(Path.Combine(root, "file.bin"), []);
|
||||
File.WriteAllBytes(Path.Combine(subdir, "file.bin"), []);
|
||||
File.CreateSymbolicLink(Path.Combine(subdir, "link.bin"), "file.bin");
|
||||
Directory.CreateSymbolicLink(Path.Combine(subdir, "self"), ".");
|
||||
return root;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,6 +513,10 @@ namespace SabreTools.IO.Extensions
|
||||
{
|
||||
try
|
||||
{
|
||||
// Outside of Windows, linked directories are not followed
|
||||
if (SkipLinkedDirectories(searchPattern, searchOption))
|
||||
return [.. WalkWithoutLinkedDirectories(path, searchPattern, Directory.GetDirectories, Directory.GetDirectories)];
|
||||
|
||||
return Directory.GetDirectories(path, searchPattern, searchOption);
|
||||
}
|
||||
catch
|
||||
@@ -555,6 +559,10 @@ namespace SabreTools.IO.Extensions
|
||||
{
|
||||
try
|
||||
{
|
||||
// Outside of Windows, linked directories are not followed
|
||||
if (SkipLinkedDirectories(searchPattern, searchOption))
|
||||
return [.. WalkWithoutLinkedDirectories(path, searchPattern, Directory.GetFiles, Directory.GetDirectories)];
|
||||
|
||||
return Directory.GetFiles(path, searchPattern, searchOption);
|
||||
}
|
||||
catch
|
||||
@@ -685,6 +693,10 @@ namespace SabreTools.IO.Extensions
|
||||
{
|
||||
try
|
||||
{
|
||||
// Outside of Windows, linked directories are not followed
|
||||
if (SkipLinkedDirectories(searchPattern, searchOption))
|
||||
return WalkWithoutLinkedDirectories(path, searchPattern, SafeEnumerateDirectories, SafeEnumerateDirectories);
|
||||
|
||||
var enumerable = Directory.EnumerateDirectories(path, searchPattern, searchOption);
|
||||
return enumerable.SafeEnumerate();
|
||||
}
|
||||
@@ -727,6 +739,10 @@ namespace SabreTools.IO.Extensions
|
||||
{
|
||||
try
|
||||
{
|
||||
// Outside of Windows, linked directories are not followed
|
||||
if (SkipLinkedDirectories(searchPattern, searchOption))
|
||||
return WalkWithoutLinkedDirectories(path, searchPattern, SafeEnumerateFiles, SafeEnumerateDirectories);
|
||||
|
||||
var enumerable = Directory.EnumerateFiles(path, searchPattern, searchOption);
|
||||
return enumerable.SafeEnumerate();
|
||||
}
|
||||
@@ -769,6 +785,10 @@ namespace SabreTools.IO.Extensions
|
||||
{
|
||||
try
|
||||
{
|
||||
// Outside of Windows, linked directories are not followed
|
||||
if (SkipLinkedDirectories(searchPattern, searchOption))
|
||||
return WalkWithoutLinkedDirectories(path, searchPattern, SafeEnumerateFileSystemEntries, SafeEnumerateDirectories);
|
||||
|
||||
var enumerable = Directory.EnumerateFileSystemEntries(path, searchPattern, searchOption);
|
||||
return enumerable.SafeEnumerate();
|
||||
}
|
||||
@@ -818,6 +838,10 @@ namespace SabreTools.IO.Extensions
|
||||
{
|
||||
try
|
||||
{
|
||||
// Outside of Windows, linked directories are not followed
|
||||
if (SkipLinkedDirectories(searchPattern, searchOption))
|
||||
return WalkWithoutLinkedDirectories(path, searchPattern, SafeEnumerateDirectories, SafeEnumerateDirectories);
|
||||
|
||||
var enumerationOptions = FromSearchOption(searchOption);
|
||||
enumerationOptions.IgnoreInaccessible = true;
|
||||
var enumerable = Directory.EnumerateDirectories(path, searchPattern, enumerationOptions);
|
||||
@@ -869,6 +893,10 @@ namespace SabreTools.IO.Extensions
|
||||
{
|
||||
try
|
||||
{
|
||||
// Outside of Windows, linked directories are not followed
|
||||
if (SkipLinkedDirectories(searchPattern, searchOption))
|
||||
return WalkWithoutLinkedDirectories(path, searchPattern, SafeEnumerateFiles, SafeEnumerateDirectories);
|
||||
|
||||
var enumerationOptions = FromSearchOption(searchOption);
|
||||
enumerationOptions.IgnoreInaccessible = true;
|
||||
var enumerable = Directory.EnumerateFiles(path, searchPattern, enumerationOptions);
|
||||
@@ -920,6 +948,10 @@ namespace SabreTools.IO.Extensions
|
||||
{
|
||||
try
|
||||
{
|
||||
// Outside of Windows, linked directories are not followed
|
||||
if (SkipLinkedDirectories(searchPattern, searchOption))
|
||||
return WalkWithoutLinkedDirectories(path, searchPattern, SafeEnumerateFileSystemEntries, SafeEnumerateDirectories);
|
||||
|
||||
var enumerationOptions = FromSearchOption(searchOption);
|
||||
enumerationOptions.IgnoreInaccessible = true;
|
||||
var enumerable = Directory.EnumerateFileSystemEntries(path, searchPattern, enumerationOptions);
|
||||
@@ -944,6 +976,89 @@ namespace SabreTools.IO.Extensions
|
||||
}
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Lists the entries of one directory that match a pattern
|
||||
/// </summary>
|
||||
private delegate IEnumerable<string> DirectoryListing(string directory, string searchPattern);
|
||||
|
||||
/// <summary>
|
||||
/// Indicates if a path is a linked directory
|
||||
/// </summary>
|
||||
/// <param name="path">Path to check</param>
|
||||
/// <returns>True if the path is a directory that carries FileAttributes.ReparsePoint, false otherwise</returns>
|
||||
/// <remarks>
|
||||
/// Returns false if the directory cannot be read, such as below a directory without
|
||||
/// execute permission, so the directory is entered and fails like in the recursive search
|
||||
/// </remarks>
|
||||
private static bool IsLinkedDirectory(string path)
|
||||
{
|
||||
// Exists returns false instead of throwing if the directory cannot be read
|
||||
var info = new DirectoryInfo(path);
|
||||
if (!info.Exists)
|
||||
return false;
|
||||
|
||||
#if NET20 || NET35
|
||||
return (info.Attributes & FileAttributes.ReparsePoint) != 0;
|
||||
#else
|
||||
return info.Attributes.HasFlag(FileAttributes.ReparsePoint);
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indicates if a search walks the directories itself to skip linked directories
|
||||
/// </summary>
|
||||
/// <param name="searchPattern">Pattern to match the entry names against</param>
|
||||
/// <param name="searchOption">Whether to search the top directory only or all subdirectories</param>
|
||||
/// <returns>True if the walk is used, false if the recursive search is used</returns>
|
||||
private static bool SkipLinkedDirectories(string searchPattern, SearchOption searchOption)
|
||||
{
|
||||
// Only a search of all subdirectories enters subdirectories
|
||||
if (searchOption != SearchOption.AllDirectories)
|
||||
return false;
|
||||
|
||||
// Linked directories on discs are only read as links outside of Windows
|
||||
if (SystemEnvironment.IsWindows())
|
||||
return false;
|
||||
|
||||
// A directory part of the pattern is matched once from the top directory, but the walk would match it in each directory
|
||||
if (!string.IsNullOrEmpty(Path.GetDirectoryName(searchPattern)))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walk a directory and all subdirectories without following linked directories
|
||||
/// </summary>
|
||||
/// <param name="path">Directory to search</param>
|
||||
/// <param name="searchPattern">Pattern to match the entry names against</param>
|
||||
/// <param name="getEntries">Lists the matching entries of one directory</param>
|
||||
/// <param name="getDirectories">Lists the subdirectories of one directory</param>
|
||||
/// <returns>The same entries in the same order as the recursive search, minus those under linked directories</returns>
|
||||
/// <remarks>
|
||||
/// A linked directory that points back into the tree, such as a UDF symlink whose
|
||||
/// target reads as empty on Linux, makes the recursive search run without end
|
||||
/// </remarks>
|
||||
private static IEnumerable<string> WalkWithoutLinkedDirectories(string path, string searchPattern, DirectoryListing getEntries, DirectoryListing getDirectories)
|
||||
{
|
||||
var pending = new Queue<string>();
|
||||
pending.Enqueue(path);
|
||||
while (pending.Count > 0)
|
||||
{
|
||||
string directory = pending.Dequeue();
|
||||
foreach (string entry in getEntries(directory, searchPattern))
|
||||
{
|
||||
yield return entry;
|
||||
}
|
||||
|
||||
foreach (string subdirectory in getDirectories(directory, "*"))
|
||||
{
|
||||
if (!IsLinkedDirectory(subdirectory))
|
||||
pending.Enqueue(subdirectory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user