From 829d98d1c77d59e3b73e39b0d31730b3545cff82 Mon Sep 17 00:00:00 2001 From: Kasper Marstal Date: Tue, 7 Apr 2026 21:04:36 +0200 Subject: [PATCH 1/2] fix: Skip inaccessible directories during file search (Fixes CLIENT-K6) --- ...reInaccessibleDirectoryInfoWrapperTests.cs | 85 +++++++++++++++++++ .../FileSearch/FileSearchRequestHandler.cs | 3 +- .../IgnoreInaccessibleDirectoryInfoWrapper.cs | 32 +++++++ 3 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 src/Cellm.Tests/Unit/Tools/IgnoreInaccessibleDirectoryInfoWrapperTests.cs create mode 100644 src/Cellm/Tools/FileSearch/IgnoreInaccessibleDirectoryInfoWrapper.cs diff --git a/src/Cellm.Tests/Unit/Tools/IgnoreInaccessibleDirectoryInfoWrapperTests.cs b/src/Cellm.Tests/Unit/Tools/IgnoreInaccessibleDirectoryInfoWrapperTests.cs new file mode 100644 index 00000000..6d34f755 --- /dev/null +++ b/src/Cellm.Tests/Unit/Tools/IgnoreInaccessibleDirectoryInfoWrapperTests.cs @@ -0,0 +1,85 @@ +using System.IO; +using System.Linq; +using Cellm.Tools.FileSearch; +using Microsoft.Extensions.FileSystemGlobbing; +using Microsoft.Extensions.FileSystemGlobbing.Abstractions; +using Xunit; + +namespace Cellm.Tests.Unit.Tools; + +public class IgnoreInaccessibleDirectoryInfoWrapperTests +{ + [Fact] + public void EnumerateFileSystemInfos_ReturnsFilesInAccessibleDirectory() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + + try + { + File.WriteAllText(Path.Combine(tempDir, "test.txt"), "hello"); + + var wrapper = new IgnoreInaccessibleDirectoryInfoWrapper(new DirectoryInfo(tempDir)); + var entries = wrapper.EnumerateFileSystemInfos().ToList(); + + Assert.Single(entries); + Assert.Equal("test.txt", entries[0].Name); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Fact] + public void EnumerateFileSystemInfos_SkipsInaccessibleDirectories() + { + // C:\ProgramData contains junction points (Application Data, Desktop, etc.) + // that throw UnauthorizedAccessException when enumerated with the stock + // DirectoryInfoWrapper. This is the same class of error as CLIENT-K6. + var programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData); + + // Stock wrapper throws on problematic junction points: + // - IOException when recursive junctions cause path-too-long + // - UnauthorizedAccessException when junctions deny access + var stockWrapper = new DirectoryInfoWrapper(new DirectoryInfo(programData)); + var ex = Record.Exception(() => + new Matcher().AddInclude("**/*").Execute(stockWrapper).Files.ToList()); + Assert.True(ex is IOException or UnauthorizedAccessException, + $"Expected IOException or UnauthorizedAccessException, got {ex?.GetType().Name}: {ex?.Message}"); + + // Our wrapper skips them + var safeWrapper = new IgnoreInaccessibleDirectoryInfoWrapper(new DirectoryInfo(programData)); + var result = new Matcher().AddInclude("**/*").Execute(safeWrapper); + + // ProgramData has files, so we should get matches without throwing + Assert.True(result.HasMatches); + } + + [Fact] + public void Execute_WithMatcher_ReturnsMatchingFiles() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + var subDir = Path.Combine(tempDir, "sub"); + Directory.CreateDirectory(subDir); + + try + { + File.WriteAllText(Path.Combine(subDir, "match.cs"), "code"); + File.WriteAllText(Path.Combine(subDir, "skip.txt"), "text"); + + var matcher = new Matcher(); + matcher.AddInclude("**/*.cs"); + + var result = matcher.Execute(new IgnoreInaccessibleDirectoryInfoWrapper(new DirectoryInfo(tempDir))); + + Assert.True(result.HasMatches); + Assert.Single(result.Files); + Assert.EndsWith("match.cs", result.Files.First().Path); + } + finally + { + Directory.Delete(tempDir, true); + } + } +} diff --git a/src/Cellm/Tools/FileSearch/FileSearchRequestHandler.cs b/src/Cellm/Tools/FileSearch/FileSearchRequestHandler.cs index 900d2715..d5bd43cf 100644 --- a/src/Cellm/Tools/FileSearch/FileSearchRequestHandler.cs +++ b/src/Cellm/Tools/FileSearch/FileSearchRequestHandler.cs @@ -10,7 +10,8 @@ public Task Handle(FileSearchRequest request, CancellationTo var matcher = new Matcher(); matcher.AddIncludePatterns(request.IncludePatterns); matcher.AddExcludePatterns(request.ExcludePatterns ?? []); - var fileNames = matcher.GetResultsInFullPath(request.RootPath); + var result = matcher.Execute(new IgnoreInaccessibleDirectoryInfoWrapper(new DirectoryInfo(request.RootPath))); + var fileNames = result.Files.Select(x => Path.GetFullPath(Path.Combine(request.RootPath, x.Path))); return Task.FromResult(new FileSearchResponse(fileNames.ToList())); } diff --git a/src/Cellm/Tools/FileSearch/IgnoreInaccessibleDirectoryInfoWrapper.cs b/src/Cellm/Tools/FileSearch/IgnoreInaccessibleDirectoryInfoWrapper.cs new file mode 100644 index 00000000..ab35086d --- /dev/null +++ b/src/Cellm/Tools/FileSearch/IgnoreInaccessibleDirectoryInfoWrapper.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.FileSystemGlobbing.Abstractions; + +namespace Cellm.Tools.FileSearch; + +internal class IgnoreInaccessibleDirectoryInfoWrapper(DirectoryInfo directoryInfo) : DirectoryInfoBase +{ + public override string Name => directoryInfo.Name; + + public override string FullName => directoryInfo.FullName; + + public override DirectoryInfoBase? ParentDirectory => + directoryInfo.Parent is not null ? new IgnoreInaccessibleDirectoryInfoWrapper(directoryInfo.Parent) : null; + + public override IEnumerable EnumerateFileSystemInfos() + { + var options = new EnumerationOptions { IgnoreInaccessible = true }; + + foreach (var info in directoryInfo.EnumerateFileSystemInfos("*", options)) + { + if (info is DirectoryInfo dir) + yield return new IgnoreInaccessibleDirectoryInfoWrapper(dir); + else if (info is FileInfo file) + yield return new FileInfoWrapper(file); + } + } + + public override DirectoryInfoBase GetDirectory(string path) => + new IgnoreInaccessibleDirectoryInfoWrapper(new DirectoryInfo(Path.Combine(directoryInfo.FullName, path))); + + public override FileInfoBase GetFile(string path) => + new FileInfoWrapper(new FileInfo(Path.Combine(directoryInfo.FullName, path))); +} From 8dd118b4eaa8398cc2a029dcfe23a186f8e8377a Mon Sep 17 00:00:00 2001 From: Kasper Marstal Date: Tue, 7 Apr 2026 21:08:45 +0200 Subject: [PATCH 2/2] fix: Remove Sentry issue reference from test comment --- .../Unit/Tools/IgnoreInaccessibleDirectoryInfoWrapperTests.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Cellm.Tests/Unit/Tools/IgnoreInaccessibleDirectoryInfoWrapperTests.cs b/src/Cellm.Tests/Unit/Tools/IgnoreInaccessibleDirectoryInfoWrapperTests.cs index 6d34f755..89d92f59 100644 --- a/src/Cellm.Tests/Unit/Tools/IgnoreInaccessibleDirectoryInfoWrapperTests.cs +++ b/src/Cellm.Tests/Unit/Tools/IgnoreInaccessibleDirectoryInfoWrapperTests.cs @@ -35,8 +35,7 @@ public void EnumerateFileSystemInfos_ReturnsFilesInAccessibleDirectory() public void EnumerateFileSystemInfos_SkipsInaccessibleDirectories() { // C:\ProgramData contains junction points (Application Data, Desktop, etc.) - // that throw UnauthorizedAccessException when enumerated with the stock - // DirectoryInfoWrapper. This is the same class of error as CLIENT-K6. + // that throw when enumerated with the stock DirectoryInfoWrapper. var programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData); // Stock wrapper throws on problematic junction points: