diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index d8b56bf77a..5f528990c2 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -213,9 +213,9 @@ Interactive terminal controls are allowed only when stdout is not redirected or ### C# / .NET integration -`SolutionProjectResolver` parses the plain-text `.sln` `Project(...) = "...", "...csproj"` entries with a non-regex parser and resolves C# / F# / VB project files. Project entries that normalize outside the active workspace root are ignored before filesystem probing or path-filter evaluation. Solution parsing rejects `.sln` files above 8 MiB, lines above 16,384 characters, and more than 4096 .NET project references with clear diagnostics. When exactly one `.sln` exists at the workspace root, `--project ` uses it automatically; otherwise callers can pass `--solution `. +`SolutionProjectResolver` parses the plain-text `.sln` `Project(...) = "...", "...csproj"` entries with a non-regex parser and resolves C# / F# / VB project files. Project entries that normalize outside the active workspace root are ignored before filesystem probing or path-filter evaluation. Solution parsing rejects `.sln` files above 8 MiB, lines above 16,384 characters, and more than 4096 .NET project references with clear diagnostics. Automatic root-level `.sln` discovery samples at most 128 candidates before sorting and reports a clear error when that cap is exceeded, so callers should pass `--solution ` in solution-heavy workspaces. When exactly one `.sln` exists at the workspace root within that cap, `--project ` uses it automatically; otherwise callers can pass `--solution `. Fallback project discovery caps traversal at 4096 directories and 65,536 files with a clear `--solution ` recovery hint. Fallback project discovery and project-file expansion use long-path-safe per-directory enumeration, skip unreadable subtrees, and include bounded traversal diagnostics when a project filter cannot be resolved. -Query commands that accept path filters (`search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `deps`, `impact`, `unused`, `hotspots`, and `validate`) expand `--project` into the matching project directory glob before hitting `DbReader`, so all existing SQL path predicates keep working. `index --project` expands to the files under the selected project directory and reuses the existing `--files` update path. +Query commands that accept path filters (`search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `deps`, `impact`, `unused`, `hotspots`, and `validate`) expand `--project` into the matching project directory glob before hitting `DbReader`, so all existing SQL path predicates keep working. `index --project` expands to the files under the selected project directory and reuses the existing `--files` update path, but rejects expansions above 65,536 files for one project or 131,072 unique files across all requested projects with an explicit-files recovery hint. `cdidx batch` is a CLI-side query loop for editor integrations and scripts that need several query commands against the same DB without spawning `cdidx` repeatedly. It opens one `DbContext` / `DbReader`, reads newline-delimited JSON string arrays from stdin, and dispatches only query commands through the existing `QueryCommandRunner` paths so output and validation stay identical to the standalone command shape. @@ -2344,9 +2344,9 @@ override が文書化されていない限り ANSI/progress control を抑止す ### C# / .NET 連携 -`SolutionProjectResolver` は plain-text の `.sln` に含まれる `Project(...) = "...", "...csproj"` 行を non-regex parser で読み、C# / F# / VB の project file を解決する。active workspace root の外側へ正規化される project entry は、filesystem probe や path-filter 評価の前に無視する。solution parsing は 8 MiB を超える `.sln`、16,384 文字を超える行、4096 件を超える .NET project reference を明確な diagnostic とともに拒否する。workspace root に `.sln` が 1 つだけある場合、`--project ` は自動でそれを使う。複数ある場合は caller が `--solution ` を渡せる。 +`SolutionProjectResolver` は plain-text の `.sln` に含まれる `Project(...) = "...", "...csproj"` 行を non-regex parser で読み、C# / F# / VB の project file を解決する。active workspace root の外側へ正規化される project entry は、filesystem probe や path-filter 評価の前に無視する。solution parsing は 8 MiB を超える `.sln`、16,384 文字を超える行、4096 件を超える .NET project reference を明確な diagnostic とともに拒否する。root 直下の `.sln` 自動検出は sort 前に最大 128 candidates で打ち切り、その上限を超えた場合は明確な error を返すため、solution が多い workspace では `--solution ` を渡す。上限内で workspace root に `.sln` が 1 つだけある場合、`--project ` は自動でそれを使う。複数ある場合は caller が `--solution ` を渡せる。fallback project discovery は 4096 directories / 65,536 files で traversal を打ち切り、`--solution ` を示す明確な recovery hint を返す。fallback project discovery と project-file expansion は long-path-safe な per-directory 列挙を使い、読めない subtree を skip し、project filter を解決できない場合は bounded traversal diagnostics を含める。 -path filter を受け付ける query コマンド(`search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `deps`, `impact`, `unused`, `hotspots`, `validate`)は、`--project` を対応する project directory glob に展開してから `DbReader` に渡す。これにより既存の SQL path predicate をそのまま利用できる。`index --project` は選択された project directory 配下のファイルに展開し、既存の `--files` 更新経路を再利用する。 +path filter を受け付ける query コマンド(`search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `deps`, `impact`, `unused`, `hotspots`, `validate`)は、`--project` を対応する project directory glob に展開してから `DbReader` に渡す。これにより既存の SQL path predicate をそのまま利用できる。`index --project` は選択された project directory 配下のファイルに展開し、既存の `--files` 更新経路を再利用する。ただし 1 project で 65,536 files、requested projects 全体で 131,072 unique files を超える展開は拒否し、明示的な `--files` を使う recovery hint を返す。 `cdidx batch` は、同じ DB に複数の query command を投げる editor integration や script 向けの CLI 側 query loop である。1 つの `DbContext` / `DbReader` を開き、stdin から newline-delimited JSON 文字列配列を読み、query command だけを既存の `QueryCommandRunner` 経路へ dispatch するため、出力と validation は単発コマンドと同じ形を保つ。 diff --git a/changelog.d/unreleased/3065.fixed.md b/changelog.d/unreleased/3065.fixed.md new file mode 100644 index 0000000000..3a66462b48 --- /dev/null +++ b/changelog.d/unreleased/3065.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3065 +affected: + - src/CodeIndex/Cli/SolutionProjectResolver.cs + - tests/CodeIndex.Tests/IndexCommandRunnerTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Automatic solution discovery now caps root `.sln` candidates before sorting (#3065)** — workspaces with more than 128 root-level solution files now get a clear `--solution ` diagnostic instead of materializing and sorting an unbounded candidate list. + +## 日本語 + +- **自動 solution 検出が root 直下の `.sln` candidate を sort 前に上限管理するようになりました (#3065)** — root 直下の solution file が 128 件を超える workspace では、無制限に materialize / sort せず、`--solution ` を促す明確な diagnostic を返します。 diff --git a/changelog.d/unreleased/3066.fixed.md b/changelog.d/unreleased/3066.fixed.md new file mode 100644 index 0000000000..7f0bc5ae9d --- /dev/null +++ b/changelog.d/unreleased/3066.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3066 +affected: + - src/CodeIndex/Cli/SolutionProjectResolver.cs + - tests/CodeIndex.Tests/IndexCommandRunnerTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **`index --project` now caps materialized project-file targets (#3066)** — project expansion rejects more than 65,536 files for one project or 131,072 unique files across requested projects and points users to explicit `--files` input. + +## 日本語 + +- **`index --project` が materialize する project-file targets を上限管理するようになりました (#3066)** — project expansion は 1 project で 65,536 files、requested projects 全体で 131,072 unique files を超える場合に拒否し、明示的な `--files` 入力を案内します。 diff --git a/changelog.d/unreleased/3213.fixed.md b/changelog.d/unreleased/3213.fixed.md new file mode 100644 index 0000000000..9b118be167 --- /dev/null +++ b/changelog.d/unreleased/3213.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3213 +affected: + - src/CodeIndex/Cli/SolutionProjectResolver.cs + - tests/CodeIndex.Tests/IndexCommandRunnerTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **SolutionProjectResolver fallback discovery now caps traversal work (#3213)** — fallback project discovery stops after 4096 directories or 65,536 files and reports a clear `--solution ` recovery hint instead of walking unbounded workspace trees. + +## 日本語 + +- **SolutionProjectResolver の fallback discovery が traversal 作業を上限管理するようになりました (#3213)** — fallback project discovery は 4096 directories または 65,536 files で停止し、workspace tree を無制限に走査せず `--solution ` を示す明確な recovery hint を返します。 diff --git a/changelog.d/unreleased/3214.fixed.md b/changelog.d/unreleased/3214.fixed.md new file mode 100644 index 0000000000..d333ca2b96 --- /dev/null +++ b/changelog.d/unreleased/3214.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3214 +affected: + - src/CodeIndex/Cli/SolutionProjectResolver.cs + - tests/CodeIndex.Tests/IndexCommandRunnerTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Solution project fallback traversal now handles long paths and per-directory failures (#3214)** — project discovery and expansion use long-path-safe enumeration, skip unreadable subtrees, and report bounded traversal diagnostics when a project filter cannot be resolved. + +## 日本語 + +- **solution project fallback traversal が long path と directory 単位の失敗を扱うようになりました (#3214)** — project discovery / expansion は long-path-safe な列挙を使い、読めない subtree を skip し、project filter を解決できない場合は bounded traversal diagnostics を返します。 diff --git a/src/CodeIndex/Cli/SolutionProjectResolver.cs b/src/CodeIndex/Cli/SolutionProjectResolver.cs index d29feb3083..cd974b4385 100644 --- a/src/CodeIndex/Cli/SolutionProjectResolver.cs +++ b/src/CodeIndex/Cli/SolutionProjectResolver.cs @@ -4,6 +4,46 @@ namespace CodeIndex.Cli; internal sealed record DotNetProjectInfo(string Name, string ProjectPath, string DirectoryPath); +internal readonly record struct SolutionProjectResolverLimits( + int MaxAutomaticSolutionCandidates, + int MaxFallbackDiscoveryDirectories, + int MaxFallbackDiscoveryFiles, + int MaxProjectExpansionFilesPerProject, + int MaxProjectExpansionFilesTotal, + int MaxTraversalDiagnostics) +{ + internal const int DefaultMaxAutomaticSolutionCandidates = 128; + internal const int DefaultMaxFallbackDiscoveryDirectories = 4096; + internal const int DefaultMaxFallbackDiscoveryFiles = 65536; + internal const int DefaultMaxProjectExpansionFilesPerProject = 65536; + internal const int DefaultMaxProjectExpansionFilesTotal = 131072; + internal const int DefaultMaxTraversalDiagnostics = 8; + + public static SolutionProjectResolverLimits Default { get; } = new( + DefaultMaxAutomaticSolutionCandidates, + DefaultMaxFallbackDiscoveryDirectories, + DefaultMaxFallbackDiscoveryFiles, + DefaultMaxProjectExpansionFilesPerProject, + DefaultMaxProjectExpansionFilesTotal, + DefaultMaxTraversalDiagnostics); + + public void Validate() + { + if (MaxAutomaticSolutionCandidates <= 0) + throw new ArgumentOutOfRangeException(nameof(MaxAutomaticSolutionCandidates), MaxAutomaticSolutionCandidates, "Limit must be positive."); + if (MaxFallbackDiscoveryDirectories <= 0) + throw new ArgumentOutOfRangeException(nameof(MaxFallbackDiscoveryDirectories), MaxFallbackDiscoveryDirectories, "Limit must be positive."); + if (MaxFallbackDiscoveryFiles <= 0) + throw new ArgumentOutOfRangeException(nameof(MaxFallbackDiscoveryFiles), MaxFallbackDiscoveryFiles, "Limit must be positive."); + if (MaxProjectExpansionFilesPerProject <= 0) + throw new ArgumentOutOfRangeException(nameof(MaxProjectExpansionFilesPerProject), MaxProjectExpansionFilesPerProject, "Limit must be positive."); + if (MaxProjectExpansionFilesTotal <= 0) + throw new ArgumentOutOfRangeException(nameof(MaxProjectExpansionFilesTotal), MaxProjectExpansionFilesTotal, "Limit must be positive."); + if (MaxTraversalDiagnostics <= 0) + throw new ArgumentOutOfRangeException(nameof(MaxTraversalDiagnostics), MaxTraversalDiagnostics, "Limit must be positive."); + } +} + internal static class SolutionProjectResolver { internal const long MaxSolutionFileBytes = 8L * 1024 * 1024; @@ -11,22 +51,39 @@ internal static class SolutionProjectResolver internal const int MaxSolutionProjectReferences = 4096; public static IReadOnlyList ResolveProjects(string workspaceRoot, string? solutionPath = null) + => ResolveProjects(workspaceRoot, solutionPath, SolutionProjectResolverLimits.Default); + + internal static IReadOnlyList ResolveProjects( + string workspaceRoot, + string? solutionPath, + SolutionProjectResolverLimits limits) + => ResolveProjects(workspaceRoot, solutionPath, limits, traversalDiagnostics: null); + + internal static IReadOnlyList ResolveProjects( + string workspaceRoot, + string? solutionPath, + SolutionProjectResolverLimits limits, + IList? traversalDiagnostics) { + limits.Validate(); var root = Path.GetFullPath(workspaceRoot); var indexer = CreateIndexerWithWorkspacePolicy(root); - return ResolveProjects(root, solutionPath, indexer); + return ResolveProjects(root, solutionPath, indexer, limits, traversalDiagnostics); } private static IReadOnlyList ResolveProjects( string workspaceRoot, string? solutionPath, - FileIndexer indexer) + FileIndexer indexer, + SolutionProjectResolverLimits limits, + IList? traversalDiagnostics) { - var solution = ResolveSolutionPath(workspaceRoot, solutionPath); + var solution = ResolveSolutionPath(workspaceRoot, solutionPath, limits); if (solution != null) return ParseSolution(solution, workspaceRoot, indexer); - return EnumerateFilesUsingIndexerPolicy(workspaceRoot, workspaceRoot, indexer) + var budget = ProjectTraversalBudget.ForFallbackDiscovery(limits); + return EnumerateFilesUsingIndexerPolicy(workspaceRoot, workspaceRoot, indexer, limits, budget, traversalDiagnostics) .Where(IsDotNetProjectFile) .Select(path => BuildProjectInfo(path, workspaceRoot)) .OrderBy(project => project.Name, StringComparer.OrdinalIgnoreCase) @@ -42,13 +99,18 @@ public static IReadOnlyList ResolveProjectDirectoryGlobs( if (requestedProjects.Count == 0) return []; - var projects = ResolveProjects(workspaceRoot, solutionPath); + var traversalDiagnostics = new List(); + var projects = ResolveProjects(workspaceRoot, solutionPath, SolutionProjectResolverLimits.Default, traversalDiagnostics); var globs = new List(); foreach (var requested in requestedProjects) { var match = MatchProject(projects, requested); if (match == null) - throw new InvalidOperationException($"project not found in solution/workspace: {requested}"); + { + throw new InvalidOperationException(AppendTraversalDiagnostics( + $"project not found in solution/workspace: {requested}", + traversalDiagnostics)); + } var relativeDir = Path.GetRelativePath(Path.GetFullPath(workspaceRoot), match.DirectoryPath) .Replace(Path.DirectorySeparatorChar, '/') @@ -63,32 +125,71 @@ public static IReadOnlyList ResolveProjectFiles( string workspaceRoot, IReadOnlyList requestedProjects, string? solutionPath = null) + => ResolveProjectFiles(workspaceRoot, requestedProjects, solutionPath, SolutionProjectResolverLimits.Default); + + internal static IReadOnlyList ResolveProjectFiles( + string workspaceRoot, + IReadOnlyList requestedProjects, + string? solutionPath, + SolutionProjectResolverLimits limits) { if (requestedProjects.Count == 0) return []; + limits.Validate(); var root = Path.GetFullPath(workspaceRoot); var indexer = CreateIndexerWithWorkspacePolicy(root); - var projects = ResolveProjects(root, solutionPath, indexer); + var traversalDiagnostics = new List(); + var projects = ResolveProjects(root, solutionPath, indexer, limits, traversalDiagnostics); var files = new SortedSet(StringComparer.Ordinal); + var totalExpandedFiles = 0; foreach (var requested in requestedProjects) { var match = MatchProject(projects, requested); if (match == null) - throw new InvalidOperationException($"project not found in solution/workspace: {requested}"); + { + throw new InvalidOperationException(AppendTraversalDiagnostics( + $"project not found in solution/workspace: {requested}", + traversalDiagnostics)); + } - foreach (var file in EnumerateFilesUsingIndexerPolicy(root, match.DirectoryPath, indexer)) + var projectExpandedFiles = 0; + foreach (var file in EnumerateFilesUsingIndexerPolicy(root, match.DirectoryPath, indexer, limits, budget: null, traversalDiagnostics)) { var relative = Path.GetRelativePath(root, file) .Replace(Path.DirectorySeparatorChar, '/') .Replace(Path.AltDirectorySeparatorChar, '/'); - files.Add(relative); + projectExpandedFiles++; + if (projectExpandedFiles > limits.MaxProjectExpansionFilesPerProject) + ThrowProjectExpansionPerProjectLimitExceeded(limits, requested, match); + + if (files.Add(relative)) + { + totalExpandedFiles++; + if (totalExpandedFiles > limits.MaxProjectExpansionFilesTotal) + ThrowProjectExpansionTotalLimitExceeded(limits); + } } } return files.ToList(); } + private static void ThrowProjectExpansionPerProjectLimitExceeded( + SolutionProjectResolverLimits limits, + string requested, + DotNetProjectInfo match) + { + throw new InvalidOperationException( + $"project filter expansion for {requested} ({match.ProjectPath}) materialized more than {limits.MaxProjectExpansionFilesPerProject} files; narrow --project/--solution or pass explicit --files."); + } + + private static void ThrowProjectExpansionTotalLimitExceeded(SolutionProjectResolverLimits limits) + { + throw new InvalidOperationException( + $"project filter expansion materialized more than {limits.MaxProjectExpansionFilesTotal} unique files across requested projects; narrow --project/--solution or pass explicit --files."); + } + private static FileIndexer CreateIndexerWithWorkspacePolicy(string workspaceRoot) { var root = Path.GetFullPath(workspaceRoot); @@ -97,19 +198,32 @@ private static FileIndexer CreateIndexerWithWorkspacePolicy(string workspaceRoot return new FileIndexer(root, ignoreCase, ignoreRuleRoot); } - private static string? ResolveSolutionPath(string workspaceRoot, string? solutionPath) + private static string? ResolveSolutionPath( + string workspaceRoot, + string? solutionPath, + SolutionProjectResolverLimits limits) { if (!string.IsNullOrWhiteSpace(solutionPath)) { var path = Path.IsPathRooted(solutionPath) ? solutionPath : Path.Combine(workspaceRoot, solutionPath); - return File.Exists(path) ? Path.GetFullPath(path) : throw new FileNotFoundException($"solution not found: {solutionPath}", path); + return File.Exists(LongPath.EnsureWindowsPrefix(path)) ? Path.GetFullPath(path) : throw new FileNotFoundException($"solution not found: {solutionPath}", path); } - var solutions = Directory.EnumerateFiles(workspaceRoot, "*.sln", SearchOption.TopDirectoryOnly) - .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) - .ToList(); + var solutions = new List(); + foreach (var solution in Directory.EnumerateFiles(LongPath.EnsureWindowsPrefix(workspaceRoot), "*.sln", SearchOption.TopDirectoryOnly)) + { + if (solutions.Count >= limits.MaxAutomaticSolutionCandidates) + { + throw new InvalidOperationException( + $"automatic solution discovery found more than {limits.MaxAutomaticSolutionCandidates} .sln files at {workspaceRoot}; pass --solution to select a solution explicitly."); + } + + solutions.Add(LongPath.RemoveWindowsPrefix(solution)); + } + + solutions.Sort(StringComparer.OrdinalIgnoreCase); return solutions.Count == 1 ? solutions[0] : null; } @@ -124,7 +238,7 @@ private static IReadOnlyList ParseSolution( var projects = new List(); var lineNumber = 0; var projectReferenceCount = 0; - foreach (var line in File.ReadLines(solutionPath)) + foreach (var line in File.ReadLines(LongPath.EnsureWindowsPrefix(solutionPath))) { lineNumber++; if (line.Length > MaxSolutionLineChars) @@ -151,7 +265,7 @@ private static IReadOnlyList ParseSolution( if (!IsPathEqualOrParent(root, fullPath)) continue; - if (File.Exists(fullPath) && !indexer.EvaluatePathFilter(fullPath).ShouldSkip) + if (File.Exists(LongPath.EnsureWindowsPrefix(fullPath)) && !indexer.EvaluatePathFilter(fullPath).ShouldSkip) projects.Add(BuildProjectInfo(fullPath, root, name)); } @@ -184,36 +298,267 @@ private static DotNetProjectInfo BuildProjectInfo(string fullProjectPath, string private static IEnumerable EnumerateFilesUsingIndexerPolicy( string workspaceRoot, string startDirectory, - FileIndexer indexer) + FileIndexer indexer, + SolutionProjectResolverLimits limits, + ProjectTraversalBudget? budget, + IList? traversalDiagnostics) { var root = Path.GetFullPath(workspaceRoot); var start = Path.GetFullPath(startDirectory); - if (!IsPathEqualOrParent(root, start) || indexer.EvaluatePathFilter(start, isDirectory: true).ShouldSkip) - yield break; - if (!PathCasing.PathsEqual(root, start) && indexer.ShouldSkipDirectoryTraversal(start)) + if (!IsPathEqualOrParent(root, start) || ShouldSkipDirectoryForTraversal(root, start, indexer, limits, traversalDiagnostics)) yield break; var pending = new Stack(); + budget?.RecordDirectory(root, start); pending.Push(start); while (pending.Count > 0) { var directory = pending.Pop(); - foreach (var childDirectory in Directory.EnumerateDirectories(directory)) + foreach (var childDirectory in EnumerateChildDirectories(root, directory, limits, budget, traversalDiagnostics)) { - if (indexer.ShouldSkipDirectoryTraversal(childDirectory)) - continue; - if (!indexer.EvaluatePathFilter(childDirectory, isDirectory: true).ShouldSkip) + if (!ShouldSkipDirectoryForTraversal(root, childDirectory, indexer, limits, traversalDiagnostics)) pending.Push(childDirectory); } - foreach (var file in Directory.EnumerateFiles(directory)) + foreach (var file in EnumerateDirectoryFiles(root, directory, limits, budget, traversalDiagnostics)) { - if (!indexer.EvaluatePathFilter(file).ShouldSkip) + if (ShouldIncludeFileForTraversal(root, file, indexer, limits, traversalDiagnostics)) yield return file; } } } + private static bool ShouldSkipDirectoryForTraversal( + string workspaceRoot, + string directory, + FileIndexer indexer, + SolutionProjectResolverLimits limits, + IList? traversalDiagnostics) + { + try + { + if (!PathCasing.PathsEqual(Path.GetFullPath(workspaceRoot), Path.GetFullPath(directory)) + && indexer.ShouldSkipDirectoryTraversal(directory)) + { + return true; + } + + return indexer.EvaluatePathFilter(directory, isDirectory: true).ShouldSkip; + } + catch (UnauthorizedAccessException) + { + AddTraversalDiagnostic(workspaceRoot, directory, "directory filters", "permissions", limits, traversalDiagnostics); + } + catch (IOException) + { + AddTraversalDiagnostic(workspaceRoot, directory, "directory filters", "an I/O error", limits, traversalDiagnostics); + } + + return true; + } + + private static bool ShouldIncludeFileForTraversal( + string workspaceRoot, + string file, + FileIndexer indexer, + SolutionProjectResolverLimits limits, + IList? traversalDiagnostics) + { + try + { + return !indexer.EvaluatePathFilter(file).ShouldSkip; + } + catch (UnauthorizedAccessException) + { + AddTraversalDiagnostic(workspaceRoot, file, "file filters", "permissions", limits, traversalDiagnostics); + } + catch (IOException) + { + AddTraversalDiagnostic(workspaceRoot, file, "file filters", "an I/O error", limits, traversalDiagnostics); + } + + return false; + } + + private static IEnumerable EnumerateChildDirectories( + string workspaceRoot, + string directory, + SolutionProjectResolverLimits limits, + ProjectTraversalBudget? budget, + IList? traversalDiagnostics) + => EnumerateDirectoryEntries( + workspaceRoot, + directory, + "subdirectories", + ProjectTraversalEntryKind.Directory, + Directory.EnumerateDirectories, + limits, + budget, + traversalDiagnostics); + + private static IEnumerable EnumerateDirectoryFiles( + string workspaceRoot, + string directory, + SolutionProjectResolverLimits limits, + ProjectTraversalBudget? budget, + IList? traversalDiagnostics) + => EnumerateDirectoryEntries( + workspaceRoot, + directory, + "files", + ProjectTraversalEntryKind.File, + Directory.EnumerateFiles, + limits, + budget, + traversalDiagnostics); + + private static IEnumerable EnumerateDirectoryEntries( + string workspaceRoot, + string directory, + string entryKind, + ProjectTraversalEntryKind budgetKind, + Func> enumerate, + SolutionProjectResolverLimits limits, + ProjectTraversalBudget? budget, + IList? traversalDiagnostics) + { + IEnumerable entries; + try + { + entries = enumerate(LongPath.EnsureWindowsPrefix(directory)); + } + catch (UnauthorizedAccessException) + { + AddTraversalDiagnostic(workspaceRoot, directory, entryKind, "permissions", limits, traversalDiagnostics); + yield break; + } + catch (IOException) + { + AddTraversalDiagnostic(workspaceRoot, directory, entryKind, "an I/O error", limits, traversalDiagnostics); + yield break; + } + + using var enumerator = entries.GetEnumerator(); + while (true) + { + string entry; + try + { + if (!enumerator.MoveNext()) + yield break; + entry = LongPath.RemoveWindowsPrefix(enumerator.Current); + } + catch (UnauthorizedAccessException) + { + AddTraversalDiagnostic(workspaceRoot, directory, entryKind, "permissions", limits, traversalDiagnostics); + yield break; + } + catch (IOException) + { + AddTraversalDiagnostic(workspaceRoot, directory, entryKind, "an I/O error", limits, traversalDiagnostics); + yield break; + } + + budget?.RecordEntry(workspaceRoot, entry, budgetKind); + yield return entry; + } + } + + private enum ProjectTraversalEntryKind + { + Directory, + File, + } + + private sealed class ProjectTraversalBudget + { + private readonly int _maxDirectories; + private readonly int _maxFiles; + private readonly string _context; + private readonly string _recoveryHint; + private int _directoriesTraversed; + private int _filesTraversed; + + private ProjectTraversalBudget(int maxDirectories, int maxFiles, string context, string recoveryHint) + { + _maxDirectories = maxDirectories; + _maxFiles = maxFiles; + _context = context; + _recoveryHint = recoveryHint; + } + + public static ProjectTraversalBudget ForFallbackDiscovery(SolutionProjectResolverLimits limits) + => new( + limits.MaxFallbackDiscoveryDirectories, + limits.MaxFallbackDiscoveryFiles, + "fallback project discovery", + "pass --solution to avoid fallback workspace discovery"); + + public void RecordDirectory(string workspaceRoot, string directory) + { + _directoriesTraversed++; + if (_directoriesTraversed > _maxDirectories) + ThrowExceeded(workspaceRoot, directory, "directories", _maxDirectories); + } + + public void RecordEntry(string workspaceRoot, string path, ProjectTraversalEntryKind kind) + { + if (kind == ProjectTraversalEntryKind.Directory) + { + RecordDirectory(workspaceRoot, path); + return; + } + + _filesTraversed++; + if (_filesTraversed > _maxFiles) + ThrowExceeded(workspaceRoot, path, "files", _maxFiles); + } + + private void ThrowExceeded(string workspaceRoot, string path, string unit, int limit) + { + throw new InvalidOperationException( + $"{_context} traversed more than {limit} {unit} under {workspaceRoot}; last path: {FormatRelativePathForDiagnostic(workspaceRoot, path)}; {_recoveryHint}."); + } + } + + private static void AddTraversalDiagnostic( + string workspaceRoot, + string directory, + string entryKind, + string reason, + SolutionProjectResolverLimits limits, + IList? traversalDiagnostics) + { + if (traversalDiagnostics == null) + return; + + if (traversalDiagnostics.Count < limits.MaxTraversalDiagnostics) + { + traversalDiagnostics.Add( + $"Could not enumerate {entryKind} in {FormatRelativePathForDiagnostic(workspaceRoot, directory)} due to {reason}."); + } + else if (traversalDiagnostics.Count == limits.MaxTraversalDiagnostics) + { + traversalDiagnostics.Add($"Additional traversal diagnostics omitted after {limits.MaxTraversalDiagnostics} entries."); + } + } + + private static string AppendTraversalDiagnostics(string message, IReadOnlyList traversalDiagnostics) + { + if (traversalDiagnostics.Count == 0) + return message; + + return $"{message}. Traversal diagnostics: {string.Join(" ", traversalDiagnostics)}"; + } + + private static string FormatRelativePathForDiagnostic(string workspaceRoot, string path) + { + var relative = Path.GetRelativePath(Path.GetFullPath(workspaceRoot), Path.GetFullPath(path)) + .Replace(Path.DirectorySeparatorChar, '/') + .Replace(Path.AltDirectorySeparatorChar, '/'); + return relative == "." ? "." : relative; + } + private static bool IsPathEqualOrParent(string parentPath, string childPath) { var parent = Path.GetFullPath(parentPath) @@ -234,7 +579,7 @@ private static bool IsDotNetProjectFile(string path) private static void RejectOversizedSolutionFile(string solutionPath) { - var length = new FileInfo(solutionPath).Length; + var length = new FileInfo(LongPath.EnsureWindowsPrefix(solutionPath)).Length; if (length > MaxSolutionFileBytes) { throw new InvalidOperationException( diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 6c3251ebdf..46acc8ea1b 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -1115,6 +1115,60 @@ public void ResolveProjects_SkipsIgnoredAndDefaultExcludedProjectDirectories_Iss } } + [Fact] + public void ResolveProjects_SkipsFallbackTraversalDirectoryErrors_Issue3214() + { + if (OperatingSystem.IsWindows()) + return; + + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_solution_fallback_directory_error"); + var lockedDirectory = Path.Combine(projectRoot, "locked"); + var restoreLockedDirectory = false; + try + { + Directory.CreateDirectory(Path.Combine(projectRoot, "src", "App")); + Directory.CreateDirectory(lockedDirectory); + File.WriteAllText(Path.Combine(projectRoot, "src", "App", "App.csproj"), ""); + + try + { + File.SetUnixFileMode(lockedDirectory, UnixFileMode.None); + restoreLockedDirectory = true; + _ = Directory.EnumerateFiles(lockedDirectory).ToList(); + return; + } + catch (Exception permissionEx) when (permissionEx is UnauthorizedAccessException or IOException) + { + } + catch (PlatformNotSupportedException) + { + return; + } + + var diagnostics = new List(); + var projects = SolutionProjectResolver.ResolveProjects( + projectRoot, + solutionPath: null, + SolutionProjectResolverLimits.Default, + diagnostics); + + Assert.Contains(projects, project => project.ProjectPath == "src/App/App.csproj"); + Assert.Contains(diagnostics, diagnostic => diagnostic.Contains("locked", StringComparison.Ordinal) + && diagnostic.Contains("permissions", StringComparison.Ordinal)); + + var ex = Assert.Throws( + () => SolutionProjectResolver.ResolveProjectFiles(projectRoot, ["Missing"])); + Assert.Contains("Traversal diagnostics:", ex.Message); + Assert.Contains("locked", ex.Message); + } + finally + { + if (restoreLockedDirectory) + File.SetUnixFileMode(lockedDirectory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void ResolveProjects_SkipsSolutionProjectsOutsideWorkspaceRoot_Issue3063() { @@ -1216,6 +1270,72 @@ public void ResolveProjects_RejectsTooManySolutionProjectReferences_Issue3064() } } + [Fact] + public void ResolveProjects_RejectsTooManyAutomaticSolutionCandidates_Issue3065() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_solution_candidate_limit"); + try + { + File.WriteAllText(Path.Combine(projectRoot, "A.sln"), string.Empty); + File.WriteAllText(Path.Combine(projectRoot, "B.sln"), string.Empty); + File.WriteAllText(Path.Combine(projectRoot, "C.sln"), string.Empty); + var limits = SolutionProjectResolverLimits.Default with { MaxAutomaticSolutionCandidates = 2 }; + + var ex = Assert.Throws( + () => SolutionProjectResolver.ResolveProjects(projectRoot, solutionPath: null, limits)); + + Assert.Contains("automatic solution discovery found more than 2 .sln files", ex.Message); + Assert.Contains("pass --solution ", ex.Message); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void ResolveProjects_RejectsFallbackDiscoveryDirectoryTraversalLimit_Issue3213() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_solution_fallback_directory_limit"); + try + { + Directory.CreateDirectory(Path.Combine(projectRoot, "src")); + var limits = SolutionProjectResolverLimits.Default with { MaxFallbackDiscoveryDirectories = 1 }; + + var ex = Assert.Throws( + () => SolutionProjectResolver.ResolveProjects(projectRoot, solutionPath: null, limits)); + + Assert.Contains("fallback project discovery traversed more than 1 directories", ex.Message); + Assert.Contains("pass --solution ", ex.Message); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void ResolveProjects_RejectsFallbackDiscoveryFileTraversalLimit_Issue3213() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_solution_fallback_file_limit"); + try + { + File.WriteAllText(Path.Combine(projectRoot, "A.txt"), "a"); + File.WriteAllText(Path.Combine(projectRoot, "B.txt"), "b"); + var limits = SolutionProjectResolverLimits.Default with { MaxFallbackDiscoveryFiles = 1 }; + + var ex = Assert.Throws( + () => SolutionProjectResolver.ResolveProjects(projectRoot, solutionPath: null, limits)); + + Assert.Contains("fallback project discovery traversed more than 1 files", ex.Message); + Assert.Contains("pass --solution ", ex.Message); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void ResolveProjectFiles_HonorsGitRootIgnoreRulesForNestedWorkspace_Issue2862() { @@ -1247,6 +1367,70 @@ public void ResolveProjectFiles_HonorsGitRootIgnoreRulesForNestedWorkspace_Issue } } + [Fact] + public void ResolveProjectFiles_RejectsPerProjectExpansionFileLimit_Issue3066() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_index_project_filter_per_project_limit"); + try + { + var libDir = Path.Combine(projectRoot, "src", "Lib"); + Directory.CreateDirectory(libDir); + File.WriteAllText(Path.Combine(projectRoot, "Repo.sln"), """ + Microsoft Visual Studio Solution File, Format Version 12.00 + Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lib", "src\Lib\Lib.csproj", "{11111111-1111-1111-1111-111111111111}" + EndProject + """); + File.WriteAllText(Path.Combine(libDir, "Lib.csproj"), ""); + File.WriteAllText(Path.Combine(libDir, "Class1.cs"), "class Class1 {}"); + var limits = SolutionProjectResolverLimits.Default with { MaxProjectExpansionFilesPerProject = 1 }; + + var ex = Assert.Throws( + () => SolutionProjectResolver.ResolveProjectFiles(projectRoot, ["Lib"], "Repo.sln", limits)); + + Assert.Contains("project filter expansion for Lib (src/Lib/Lib.csproj) materialized more than 1 files", ex.Message); + Assert.Contains("explicit --files", ex.Message); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void ResolveProjectFiles_RejectsTotalExpansionFileLimit_Issue3066() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_index_project_filter_total_limit"); + try + { + var libDir = Path.Combine(projectRoot, "src", "Lib"); + var appDir = Path.Combine(projectRoot, "src", "App"); + Directory.CreateDirectory(libDir); + Directory.CreateDirectory(appDir); + File.WriteAllText(Path.Combine(projectRoot, "Repo.sln"), """ + Microsoft Visual Studio Solution File, Format Version 12.00 + Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lib", "src\Lib\Lib.csproj", "{11111111-1111-1111-1111-111111111111}" + EndProject + Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App", "src\App\App.csproj", "{22222222-2222-2222-2222-222222222222}" + EndProject + """); + File.WriteAllText(Path.Combine(libDir, "Lib.csproj"), ""); + File.WriteAllText(Path.Combine(libDir, "Class1.cs"), "class Class1 {}"); + File.WriteAllText(Path.Combine(appDir, "App.csproj"), ""); + File.WriteAllText(Path.Combine(appDir, "Class2.cs"), "class Class2 {}"); + var limits = SolutionProjectResolverLimits.Default with { MaxProjectExpansionFilesTotal = 3 }; + + var ex = Assert.Throws( + () => SolutionProjectResolver.ResolveProjectFiles(projectRoot, ["Lib", "App"], "Repo.sln", limits)); + + Assert.Contains("project filter expansion materialized more than 3 unique files across requested projects", ex.Message); + Assert.Contains("explicit --files", ex.Message); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void ResolveProjectFiles_SkipsDirectorySymlinkLoops_Issue2862() {