diff --git a/Terminal.Gui/FileServices/FileSystemTreeBuilder.cs b/Terminal.Gui/FileServices/FileSystemTreeBuilder.cs index 80e9a740d0..4b616a59e1 100644 --- a/Terminal.Gui/FileServices/FileSystemTreeBuilder.cs +++ b/Terminal.Gui/FileServices/FileSystemTreeBuilder.cs @@ -72,5 +72,15 @@ private IEnumerable TryGetChildren (IFileSystemInfo entry) } } - internal static bool IsReparsePoint (IFileSystemInfo entry) => (entry.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint; + internal static bool IsReparsePoint (IFileSystemInfo entry) + { + try + { + return (entry.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint; + } + catch (Exception) + { + return false; // treat an unreadable entry as not a reparse point / not expandable + } + } } diff --git a/Terminal.Gui/Views/FileDialogs/FileDialogState.cs b/Terminal.Gui/Views/FileDialogs/FileDialogState.cs index 24340147f0..2842534052 100644 --- a/Terminal.Gui/Views/FileDialogs/FileDialogState.cs +++ b/Terminal.Gui/Views/FileDialogs/FileDialogState.cs @@ -4,6 +4,8 @@ namespace Terminal.Gui.Views; internal class FileDialogState { + private static readonly EnumerationOptions _ignoreInaccessibleEnumerationOptions = new () { IgnoreInaccessible = true }; + public FileDialogState (IDirectoryInfo dir, FileDialog parent) { Parent = parent; @@ -60,19 +62,7 @@ private void AddReadableChildren (List children, IDirectory { try { - IEnumerable entries; - - // if directories only - if (Parent.OpenMode == OpenMode.Directory) - { - entries = dir.GetDirectories (); - } - else - { - entries = dir.GetFileSystemInfos (); - } - - foreach (IFileSystemInfo entry in entries) + foreach (IFileSystemInfo entry in EnumerateReadableEntries (dir)) { AddReadableChild (children, entry); } @@ -83,6 +73,17 @@ private void AddReadableChildren (List children, IDirectory } } + private IEnumerable EnumerateReadableEntries (IDirectoryInfo dir) + { + // if directories only + if (Parent.OpenMode == OpenMode.Directory) + { + return dir.EnumerateDirectories ("*", _ignoreInaccessibleEnumerationOptions); + } + + return dir.EnumerateFileSystemInfos ("*", _ignoreInaccessibleEnumerationOptions); + } + private void AddReadableChild (List children, IFileSystemInfo entry) { try diff --git a/Tests/UnitTestsParallelizable/FileServices/FileSystemTreeBuilderTests.cs b/Tests/UnitTestsParallelizable/FileServices/FileSystemTreeBuilderTests.cs new file mode 100644 index 0000000000..3e916a86f4 --- /dev/null +++ b/Tests/UnitTestsParallelizable/FileServices/FileSystemTreeBuilderTests.cs @@ -0,0 +1,37 @@ +using System.IO.Abstractions; +using Moq; + +namespace FileServicesTests; + +public class FileSystemTreeBuilderTests +{ + [Fact] + public void CanExpand_DirectoryWithUnreadableAttributes_DoesNotThrowAndReturnsFalse () + { + Mock directory = new (); + directory.SetupGet (d => d.Attributes).Throws (new UnauthorizedAccessException ("Access denied")); + directory.SetupGet (d => d.Exists).Returns (true); + directory.Setup (d => d.GetFileSystemInfos ()).Returns ([]); + + FileSystemTreeBuilder builder = new (); + + bool canExpand = builder.CanExpand (directory.Object); + + Assert.False (canExpand); + } + + [Fact] + public void GetChildren_DirectoryWithUnreadableAttributes_DoesNotThrowAndReturnsEmpty () + { + Mock directory = new (); + directory.SetupGet (d => d.Attributes).Throws (new UnauthorizedAccessException ("Access denied")); + directory.SetupGet (d => d.Exists).Returns (true); + directory.Setup (d => d.GetFileSystemInfos ()).Returns ([]); + + FileSystemTreeBuilder builder = new (); + + IEnumerable children = builder.GetChildren (directory.Object); + + Assert.Empty (children); + } +} diff --git a/Tests/UnitTestsParallelizable/Views/FileDialogResultTests.cs b/Tests/UnitTestsParallelizable/Views/FileDialogResultTests.cs index d679958e7c..74cdfa74a6 100644 --- a/Tests/UnitTestsParallelizable/Views/FileDialogResultTests.cs +++ b/Tests/UnitTestsParallelizable/Views/FileDialogResultTests.cs @@ -265,6 +265,10 @@ public void FileDialog_MixedMode_SkipsUnreadableEntry_AndKeepsReadableEntries () .Setup (d => d.GetFileSystemInfos ()) .Returns ([goodFile, badFile, goodDirectory]); + Mock.Get (directory) + .Setup (d => d.EnumerateFileSystemInfos ("*", It.IsAny ())) + .Returns ([goodFile, badFile, goodDirectory]); + using FileDialog fd = new TestableFileDialog (fileSystem); fd.OpenMode = OpenMode.Mixed; @@ -288,6 +292,10 @@ public void FileDialog_DirectoryMode_SkipsUnreadableDirectory_AndKeepsParentNavi .Setup (d => d.GetDirectories ()) .Returns ([goodDirectory, badDirectory]); + Mock.Get (directory) + .Setup (d => d.EnumerateDirectories ("*", It.IsAny ())) + .Returns ([goodDirectory, badDirectory]); + using FileDialog fd = new TestableFileDialog (fileSystem); fd.OpenMode = OpenMode.Directory; @@ -299,6 +307,64 @@ public void FileDialog_DirectoryMode_SkipsUnreadableDirectory_AndKeepsParentNavi Assert.DoesNotContain (fd.State.Children, c => c.Name == "bad-dir"); } + [Fact] + public void FileDialog_MixedMode_WhenEagerListingThrows_StillKeepsReadableEntries () + { + IFileSystem fileSystem = CreateFileSystemWithDirectory (out IDirectoryInfo directory); + IFileInfo goodFile = CreateFile ("/testdir/good.txt", "good.txt"); + IDirectoryInfo goodDirectory = CreateDirectory ("/testdir/good-dir", "good-dir", directory); + + Mock.Get (directory) + .Setup (d => d.GetFileSystemInfos ()) + .Throws (new UnauthorizedAccessException ()); + + Mock.Get (directory) + .Setup (d => d.EnumerateFileSystemInfos ()) + .Returns ([goodFile, goodDirectory]); + + Mock.Get (directory) + .Setup (d => d.EnumerateFileSystemInfos ("*", It.IsAny ())) + .Returns ([goodFile, goodDirectory]); + + using FileDialog fd = new TestableFileDialog (fileSystem); + fd.OpenMode = OpenMode.Mixed; + + fd.Path = "/testdir"; + + Assert.NotNull (fd.State); + Assert.Contains (fd.State!.Children, c => c.Name == "good.txt"); + Assert.Contains (fd.State.Children, c => c.Name == "good-dir"); + Assert.Contains (fd.State.Children, c => c.IsParent && c.Name == ".."); + } + + [Fact] + public void FileDialog_DirectoryMode_WhenEagerListingThrows_StillKeepsReadableDirectories () + { + IFileSystem fileSystem = CreateFileSystemWithDirectory (out IDirectoryInfo directory); + IDirectoryInfo goodDirectory = CreateDirectory ("/testdir/good-dir", "good-dir", directory); + + Mock.Get (directory) + .Setup (d => d.GetDirectories ()) + .Throws (new UnauthorizedAccessException ()); + + Mock.Get (directory) + .Setup (d => d.EnumerateDirectories ()) + .Returns ([goodDirectory]); + + Mock.Get (directory) + .Setup (d => d.EnumerateDirectories ("*", It.IsAny ())) + .Returns ([goodDirectory]); + + using FileDialog fd = new TestableFileDialog (fileSystem); + fd.OpenMode = OpenMode.Directory; + + fd.Path = "/testdir"; + + Assert.NotNull (fd.State); + Assert.Contains (fd.State!.Children, c => c.Name == "good-dir"); + Assert.Contains (fd.State.Children, c => c.IsParent && c.Name == ".."); + } + [Fact] public void FileDialog_Accepting_Directory_From_Table_Keeps_Path_On_Opened_Directory () { @@ -418,6 +484,10 @@ private static Mock CreateDirectoryMock (string fullName, string directory.SetupGet (d => d.LastWriteTime).Returns (new DateTime (2026, 1, 1)); directory.Setup (d => d.GetFileSystemInfos ()).Returns ([]); directory.Setup (d => d.GetDirectories ()).Returns ([]); + directory.Setup (d => d.EnumerateFileSystemInfos ()).Returns ([]); + directory.Setup (d => d.EnumerateDirectories ()).Returns ([]); + directory.Setup (d => d.EnumerateFileSystemInfos ("*", It.IsAny ())).Returns ([]); + directory.Setup (d => d.EnumerateDirectories ("*", It.IsAny ())).Returns ([]); return directory; }