diff --git a/SabreTools.IO.Extensions.Test/IOExtensionsTests.cs b/SabreTools.IO.Extensions.Test/IOExtensionsTests.cs index d27de7e..47bec45 100644 --- a/SabreTools.IO.Extensions.Test/IOExtensionsTests.cs +++ b/SabreTools.IO.Extensions.Test/IOExtensionsTests.cs @@ -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 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 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 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 entries = [.. root.SafeEnumerateFileSystemEntries("*", SearchOption.AllDirectories)]; + Assert.Equal(5, entries.Count); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + #endregion + + /// + /// Create a directory tree with a linked file and a directory linked to the directory it is in + /// + /// Root of the tree, to be deleted by the caller + 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; + } } } diff --git a/SabreTools.IO.Extensions/IOExtensions.cs b/SabreTools.IO.Extensions/IOExtensions.cs index e508f43..f3d3330 100644 --- a/SabreTools.IO.Extensions/IOExtensions.cs +++ b/SabreTools.IO.Extensions/IOExtensions.cs @@ -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 + /// + /// Lists the entries of one directory that match a pattern + /// + private delegate IEnumerable DirectoryListing(string directory, string searchPattern); + + /// + /// Indicates if a path is a linked directory + /// + /// Path to check + /// True if the path is a directory that carries FileAttributes.ReparsePoint, false otherwise + /// + /// 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 + /// + 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 + } + + /// + /// Indicates if a search walks the directories itself to skip linked directories + /// + /// Pattern to match the entry names against + /// Whether to search the top directory only or all subdirectories + /// True if the walk is used, false if the recursive search is used + 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; + } + + /// + /// Walk a directory and all subdirectories without following linked directories + /// + /// Directory to search + /// Pattern to match the entry names against + /// Lists the matching entries of one directory + /// Lists the subdirectories of one directory + /// The same entries in the same order as the recursive search, minus those under linked directories + /// + /// 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 + /// + private static IEnumerable WalkWithoutLinkedDirectories(string path, string searchPattern, DirectoryListing getEntries, DirectoryListing getDirectories) + { + var pending = new Queue(); + 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 } }