diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 34a09d6a0..62905e0ce 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -257,7 +257,7 @@ Set `CDIDX_SLOW_QUERY_MS=` to write slow SQLite command diagnostic | Worker protocol JSON | Isolated worker stdin frames are read through `BoundedLineReader`. The symbol-worker client serializes requests directly to UTF-8 and writes newline-framed bytes to the process stream; the worker serializes responses directly to its stdout stream, and the client reads each bounded response frame as UTF-8 bytes for direct deserialization. This avoids an additional UTF-16 JSON string and encoding pass in each direction for every source file. The default frame cap is 32 MiB for both characters and UTF-8 bytes. When a larger `--max-file-bytes` setting needs JSON-escaping headroom, the protocol frame cap may expand up to `WorkerProtocolLineLimits.MaxExtendedLineUtf8Bytes` (384 MiB), never to `int.MaxValue`. `WorkerProtocolJsonValidator` rejects payloads over the negotiated character/UTF-8 byte cap before `JsonDocument.Parse`, parses with `DefaultMaxJsonDepth` (32), rejects more than 1,000,000 object properties, and rejects strings longer than the frame cap. | | User regex find | `find --regex` keeps the classic .NET regex engine for lookaround/backreference compatibility, adds `RegexOptions.CultureInvariant`, adds `IgnoreCase` unless `--exact` is set, and uses `BoundedRegex.DefaultMatchTimeout` per match. Timeouts surface as `E014_REGEX_MATCH_TIMEOUT` / `regex_timeout` in CLI JSON, and human output includes the same recovery hint. `find --all` additionally applies candidate-file and line-scan caps before walking the whole index. | | Shared regex construction | Production regex construction is centralized through `BoundedRegex`, `RegexRegistry`, or `RegexTimeoutPolicy`. Use `BoundedRegex` for extractor patterns and bounded static regex APIs, `RegexRegistry` for raw BCL regex factories that must preserve timeout exceptions (`find --regex`, ignore glob regexes, generated-code path patterns), and `RegexTimeoutPolicy` for diagnostic/redaction surfaces. `RegexRegistry` owns the named ignore-glob timeout (100 ms), generated-code pattern timeout (50 ms), and find-regex factory using `BoundedRegex.DefaultMatchTimeout`. Search-audit recipes treat only `BoundedRegex` aliases and `RegexRegistry.cs` as centralized positive evidence, so new production raw constructors require a deliberate factory or generated-regex entry plus tests. | -| Filesystem traversal helpers | `FileSystemTraversalPolicy` keeps top-directory-only enumeration explicit (`IgnoreInaccessible=false`, no implicit recursion) and exposes opt-in `CancellationToken` / entry-budget options. Expected traversal failures are classified centrally so command diagnostics share the same permission, I/O, invalid-path, unsupported-path, path-too-long, and budget-exceeded taxonomy. | +| Filesystem traversal helpers | `FileSystemTraversalPolicy` keeps top-directory-only enumeration explicit (`IgnoreInaccessible=false`, no implicit recursion) and exposes opt-in `CancellationToken` / entry-budget options. Expected traversal failures are classified centrally so command diagnostics share the same permission, I/O, invalid-path, unsupported-path, path-too-long, and budget-exceeded taxonomy. Existing-child case probes retain one exact-name set capped by `CaseSensitivityProbeDirectory.MaxExistingChildProbeEntries` (4,096), return unknown on truncation so callers use the isolated-write or cached root-policy fallback, and propagate available cancellation tokens. | | `MaxValue` sentinels | `int.MaxValue` may be used only as an internal sentinel when the next operation clamps before SQL limits, allocation, traversal, payload sizing, or timeout conversion. User-influenced values must be reduced to named practical constants before multiplication, buffer sizing, protocol framing, or query expansion. | ### Indexing pipeline @@ -4296,7 +4296,7 @@ query コマンドも JSON profile block 用の `--profile` と command-scoped p | worker protocol JSON | isolated worker の stdin frame は `BoundedLineReader` で読みます。symbol-worker client は request を直接 UTF-8 に serialize して改行区切りの byte を process stream へ書き、worker は response を stdout stream へ直接 serialize し、client は bounded response frame を UTF-8 byte のまま読み取って直接 deserialize します。これにより source file ごとに両方向で発生していた追加 UTF-16 JSON string と encoding pass を避けます。既定の frame 上限は文字数・UTF-8 byte 数ともに 32 MiB です。大きな `--max-file-bytes` によって JSON escape 分の余裕が必要な場合、protocol frame 上限は `WorkerProtocolLineLimits.MaxExtendedLineUtf8Bytes`(384 MiB)まで拡張できますが、`int.MaxValue` までは拡張しません。`WorkerProtocolJsonValidator` は `JsonDocument.Parse` の前に合意済みの文字数 / UTF-8 byte 上限を超える payload を拒否し、`DefaultMaxJsonDepth`(32)で parse し、object property 1,000,000 件超と frame 上限を超える string を拒否します。 | | user regex find | `find --regex` は lookaround / backreference 互換性のため classic .NET regex engine を維持し、`RegexOptions.CultureInvariant` を付け、`--exact` でない場合は `IgnoreCase` も付け、各 match に `BoundedRegex.DefaultMatchTimeout` を使います。timeout は CLI JSON で `E014_REGEX_MATCH_TIMEOUT` / `regex_timeout` として返り、人間向け出力にも同じ recovery hint が出ます。`find --all` は index 全体を走査する前に candidate file と line scan の上限も適用します。 | | shared regex construction | production の regex 構築は `BoundedRegex`、`RegexRegistry`、または `RegexTimeoutPolicy` に集約します。extractor pattern と bounded static regex API には `BoundedRegex`、timeout 例外を維持する必要がある raw BCL regex factory(`find --regex`、ignore glob regex、generated-code path pattern)には `RegexRegistry`、diagnostic / redaction surface には `RegexTimeoutPolicy` を使います。`RegexRegistry` は ignore glob timeout(100 ms)、generated-code pattern timeout(50 ms)、および `BoundedRegex.DefaultMatchTimeout` を使う find-regex factory の名前付き policy を所有します。search-audit recipe は `BoundedRegex` alias と `RegexRegistry.cs` だけを集約済みの positive evidence と見なすため、新しい production raw constructor は明示的な factory または generated-regex entry とテストを伴う必要があります。 | -| filesystem traversal helper | `FileSystemTraversalPolicy` は top-directory-only enumeration を明示し(`IgnoreInaccessible=false`、暗黙の再帰なし)、任意指定の `CancellationToken` / entry budget option を公開します。想定内の traversal failure は中央で分類し、command diagnostic が permission、I/O、invalid-path、unsupported-path、path-too-long、budget-exceeded の taxonomy を共有します。 | +| filesystem traversal helper | `FileSystemTraversalPolicy` は top-directory-only enumeration を明示し(`IgnoreInaccessible=false`、暗黙の再帰なし)、任意指定の `CancellationToken` / entry budget option を公開します。想定内の traversal failure は中央で分類し、command diagnostic が permission、I/O、invalid-path、unsupported-path、path-too-long、budget-exceeded の taxonomy を共有します。既存 child の case probe は `CaseSensitivityProbeDirectory.MaxExistingChildProbeEntries`(4,096)を上限とする1つの exact-name set だけを保持し、truncation 時は unknown を返して caller の isolated-write または cached root-policy fallback に委ね、利用可能な cancellation token を伝播します。 | | `MaxValue` sentinel | `int.MaxValue` は、次の操作が SQL limit、allocation、traversal、payload sizing、timeout conversion の前に clamp する場合だけ内部 sentinel として使えます。ユーザー影響値は multiplication、buffer sizing、protocol framing、query expansion の前に、名前付きの実用上限へ落としてください。 | ### インデックスパイプライン diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 5bc3248ca..99baaf967 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -192,6 +192,7 @@ Candidate-ordered parallel-index recovery tests must prove that the fatal result Project-marker fingerprint coverage requires the C#/VB/F#/MSBuild batch to share one directory-tree traversal while retaining one enumeration per distinct marker glob and one child-directory enumeration per visited directory. Pin known hashes and single-language delegation, independent budgets/truncation/warning order, ignore/nested-repository/submodule and platform-casing boundaries, traversal failure and cancellation propagation, and MCP fingerprint/authorization parity. Family-scope snapshot coverage must keep exact marker counts distinct until publication, then resolve only marker directories and their ancestors through the scan-cached per-directory case policy; use injected entry snapshots to cover case-sensitive children below insensitive roots and insensitive-child aliases without any post-scan filesystem probe or live marker enumeration. Normalized-content facts coverage compares normalization, all derived facts, chunk payloads, and validation issue order against an independent fixed-seed oracle. Keep exact UTF-16 line, unicode61 rune, normalized UTF-8 conflict-budget, replacement-line, trailing-newline, and 80/10 chunk boundaries explicit. The blocking `net8.0` allocation checks use 100,000-line inputs to prevent per-line boundary arrays from returning and require high-ratio invalid UTF-8 loads to discard replacement-line details while preserving aggregate/fallback issue parity. - `PathCompatibilityMatrixTests.cs` + Existing-child case-probe coverage keeps below-limit results compatible, proves the injectable entry cap stops after the single look-ahead needed to detect truncation, requires truncated snapshots to return unknown, and checks pre-cancellation plus cancellation during enumeration. Cross-platform path compatibility matrix coverage for path casing, boundary-prefix comparisons, private-child case probes (including numeric root basenames), filesystem-aware exact/prefix filename language detection, Windows long-path prefixing, POSIX sensitive-file permissions, symlink/dangling-entry scan behavior, submodule passthrough under default skip directories, and git skip-worktree path normalization. Keep new platform/path fixture scenarios here when the same assumption needs to be visible across indexing, Git helper, DB/query, installer, or status surfaces. - `SymbolKindCatalogTests.cs` Cross-language taxonomy coverage requires every declared symbol and reference kind to be unique and accepted by the exact Ordinal lookup, while null, empty, whitespace-only, case variants, trailing-space variants, and unknown values remain rejected. Keep the writer's unknown symbol/reference and container-kind diagnostics, schema/catalog parity, and pattern-sidecar invalid-kind rejection in the same focused validation set. @@ -1313,6 +1314,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" project marker fingerprint の coverage では、C#/VB/F#/MSBuild batch が1回の directory-tree traversal を共有しつつ、各directoryで固有marker globごとに1回、child directoryは1回だけ列挙することを必須とします。既知hashとsingle-language delegation、言語別budget/truncation/warning順、ignore/nested repository/submoduleとplatform casingの境界、traversal failure/cancellationの伝播、MCP fingerprint/authorization parityを固定してください。family-scope snapshot の coverage は、publish まで marker count を完全一致で分離し、publish 後は marker directory とその祖先だけを scan 済み directory ごとの case policy で解決する契約を維持します。case-insensitive root 配下の case-sensitive child と case-insensitive child alias は injected entry snapshot で検証し、scan 後の filesystem probe や live marker 列挙が0であることも固定してください。 normalized-content facts の coverage は、正規化、全 derived facts、chunk payload、validation issue 順を独立した固定 seed oracle と比較します。UTF-16 line、unicode61 rune、正規化後 UTF-8 の conflict budget、replacement line、末尾改行、80/10 chunk の正確な境界を明示的に維持してください。blocking な `net8.0` allocation check は100,000行 input で行単位の境界 array が戻ることを防ぎ、高比率 invalid UTF-8 load が aggregate / fallback issue parity を保ったまま replacement-line detail を破棄することを必須とします。 - `PathCompatibilityMatrixTests.cs` + 既存 child の case-probe coverage では、上限未満の結果互換性、truncation 検出に必要な1件だけの look-ahead 後に停止する注入可能な entry cap、truncated snapshot が unknown を返すこと、事前 cancellation と列挙中 cancellation を固定します。 path casing、boundary-prefix 比較、数字だけの root basename を含む private-child case probe、filesystem-aware な完全一致/prefix ファイル名言語判定、Windows long-path prefix、POSIX の sensitive file 権限、symlink / dangling entry の scan 挙動、既定 skip directory 配下の submodule passthrough、git skip-worktree path 正規化を横断する compatibility matrix カバレッジです。同じ platform/path 前提を indexing、Git helper、DB/query、installer、status の各 surface で見える形にしたい場合は、新しい fixture シナリオをここに追加してください。 - `SymbolKindCatalogTests.cs` 全言語共通の taxonomy coverage では、宣言済みの全 symbol / reference kind が重複せず、完全一致の Ordinal lookup で受理されることを必須とします。null、空文字、空白のみ、case 違い、末尾空白、未知の値は引き続き拒否してください。writer の未知 symbol/reference kind と container kind の診断、schema/catalog parity、pattern sidecar の invalid-kind 拒否も同じ focused validation set で維持します。 diff --git a/changelog.d/unreleased/5160.fixed.md b/changelog.d/unreleased/5160.fixed.md new file mode 100644 index 000000000..2af226f5f --- /dev/null +++ b/changelog.d/unreleased/5160.fixed.md @@ -0,0 +1,21 @@ +--- +category: fixed +issues: + - 5160 +affected: + - src/CodeIndex/Indexer/Scanning/CaseSensitivityProbeDirectory.cs + - src/CodeIndex/Indexer/Scanning/FileIndexer.CaseSensitivity.cs + - src/CodeIndex/Indexer/Scanning/FileIndexer.DirectoryEnumeration.cs + - src/CodeIndex/Cli/GitHelper.cs + - tests/CodeIndex.Tests/PathCompatibilityMatrixTests.cs + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Case-sensitivity probes now use bounded, cancelable directory traversal (#5160)** — existing-child probes retain one capped name snapshot, treat truncation as unknown so deterministic fallbacks remain authoritative, and observe available cancellation tokens. + +## 日本語 + +- **大小文字区別 probe が上限付き・キャンセル可能な directory traversal を使うようになりました (#5160)** — 既存 child の probe は上限付きの name snapshot を1つだけ保持し、truncation を unknown として扱うことで deterministic fallback の authority を維持しつつ、利用可能な cancellation token を監視します。 diff --git a/src/CodeIndex/Cli/GitHelper.cs b/src/CodeIndex/Cli/GitHelper.cs index ab29cadb7..af7891a26 100644 --- a/src/CodeIndex/Cli/GitHelper.cs +++ b/src/CodeIndex/Cli/GitHelper.cs @@ -1217,13 +1217,13 @@ internal static bool ResolveIgnoreCase( { var repoRoot = TryGetRepositoryRoot(projectRoot, gitEnvironmentOverrides, cancellationToken); if (repoRoot == null) - return ProbeFileSystemIgnoreCase(projectRoot); + return ProbeFileSystemIgnoreCase(projectRoot, cancellationToken); var configured = TryRunGit(repoRoot, gitEnvironmentOverrides, cancellationToken, "config", "--bool", "--get", "core.ignorecase")?.Trim(); if (bool.TryParse(configured, out var ignoreCase)) return ignoreCase; - return ProbeFileSystemIgnoreCase(projectRoot); + return ProbeFileSystemIgnoreCase(projectRoot, cancellationToken); } /// @@ -1518,7 +1518,9 @@ private static (int ExitCode, string Output, string Error)? RunProcessCapturingO private static string FormatGitDiagnostic(string diagnostic) => GitProcessRunner.FormatDiagnostic(diagnostic); - private static bool ProbeFileSystemIgnoreCase(string projectRoot) + private static bool ProbeFileSystemIgnoreCase( + string projectRoot, + CancellationToken cancellationToken) { var normalizedRoot = projectRoot; try @@ -1531,7 +1533,7 @@ private static bool ProbeFileSystemIgnoreCase(string projectRoot) { try { - if (CaseSensitivityProbeDirectory.ProbeExistingChildIgnoreCase(normalizedRoot) is { } existingChildIgnoreCase) + if (CaseSensitivityProbeDirectory.ProbeExistingChildIgnoreCase(normalizedRoot, cancellationToken) is { } existingChildIgnoreCase) return existingChildIgnoreCase; } catch (Exception ex) when (IsCaseSensitivityProbeFailure(ex)) @@ -1546,14 +1548,14 @@ private static bool ProbeFileSystemIgnoreCase(string projectRoot) if (!string.IsNullOrEmpty(parent) && !string.Equals(parent, normalizedRoot, StringComparison.Ordinal)) { - return ProbeFileSystemIgnoreCase(parent); + return ProbeFileSystemIgnoreCase(parent, cancellationToken); } throw; } } - return CaseSensitivityProbeDirectory.ProbeIgnoreCase(normalizedRoot, "case-probe-"); + return CaseSensitivityProbeDirectory.ProbeIgnoreCase(normalizedRoot, "case-probe-", cancellationToken); } catch (CaseSensitivityProbeException ex) { diff --git a/src/CodeIndex/Indexer/Scanning/CaseSensitivityProbeDirectory.cs b/src/CodeIndex/Indexer/Scanning/CaseSensitivityProbeDirectory.cs index f2ac8c2ac..88f94dd8b 100644 --- a/src/CodeIndex/Indexer/Scanning/CaseSensitivityProbeDirectory.cs +++ b/src/CodeIndex/Indexer/Scanning/CaseSensitivityProbeDirectory.cs @@ -8,6 +8,7 @@ internal static class CaseSensitivityProbeDirectory internal const string DataDirectoryName = ".cdidx"; internal const string ProbeDirectoryName = "probes"; internal const string IsolatedProbeDirectoryPrefix = ".cdidx-case-probe-"; + internal const int MaxExistingChildProbeEntries = 4096; private const UnixFileMode PrivateDirectoryMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; @@ -15,14 +16,20 @@ internal static class CaseSensitivityProbeDirectory internal static Action? DeleteCreatedEmptyDirectoryForTesting { get; set; } internal static Action? CleanupDiagnosticSinkForTesting { get; set; } - internal static bool ProbeIgnoreCase(string projectRoot, string prefix) + internal static bool ProbeIgnoreCase( + string projectRoot, + string prefix, + CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); var normalizedRoot = Path.GetFullPath(projectRoot); using var probe = CreateIsolatedProbePathScope(normalizedRoot, prefix); var probePath = probe.Path; - FileWriteProbe.WriteEmptyFile(probePath); try { + cancellationToken.ThrowIfCancellationRequested(); + FileWriteProbe.WriteEmptyFile(probePath); + cancellationToken.ThrowIfCancellationRequested(); if (TryCreateLeafNameCaseVariant(probePath, out var probeVariant)) return File.Exists(LongPath.EnsureWindowsPrefix(probeVariant)); } @@ -58,23 +65,56 @@ internal static bool TryCreateLeafNameCaseVariant(string path, out string varian return false; } - internal static bool? ProbeExistingChildIgnoreCase(string directory) + internal static bool? ProbeExistingChildIgnoreCase( + string directory, + CancellationToken cancellationToken = default, + int maxEntries = MaxExistingChildProbeEntries) { var normalizedDirectory = Path.GetFullPath(directory); - var entries = Directory.EnumerateFileSystemEntries(LongPath.EnsureWindowsPrefix(normalizedDirectory)) - .Select(LongPath.RemoveWindowsPrefix) - .ToArray(); - return ProbeExistingChildIgnoreCase(normalizedDirectory, entries); + var options = new FileSystemTraversalOptions(maxEntries, cancellationToken); + try + { + var entries = FileSystemTraversalPolicy.EnumerateFileSystemEntries( + LongPath.EnsureWindowsPrefix(normalizedDirectory), + options); + return ProbeExistingChildIgnoreCase(normalizedDirectory, entries, cancellationToken, maxEntries); + } + catch (FileSystemTraversalBudgetExceededException) + { + // A partial directory snapshot cannot establish case-sensitivity. Returning unknown + // lets callers use their isolated-write or cached root-policy fallback. + // directory snapshot が不完全な場合は大小文字 policy を確定できないため、unknown を + // 返して caller 側の isolated-write / cached root-policy fallback に委ねる。 + return null; + } } - internal static bool? ProbeExistingChildIgnoreCase(string directory, IReadOnlyList entries) + internal static bool? ProbeExistingChildIgnoreCase( + string directory, + IEnumerable entries, + CancellationToken cancellationToken = default, + int maxEntries = MaxExistingChildProbeEntries) { - _ = Path.GetFullPath(directory); - var exactNames = entries - .Select(Path.GetFileName) - .ToHashSet(StringComparer.Ordinal); - foreach (var normalizedEntry in entries) + if (maxEntries < 0) + throw new ArgumentOutOfRangeException(nameof(maxEntries), "Case-probe entry budget must be zero or greater."); + + var normalizedDirectory = Path.GetFullPath(directory); + var exactNames = new HashSet(StringComparer.Ordinal); + var entriesObserved = 0; + foreach (var entry in entries) + { + cancellationToken.ThrowIfCancellationRequested(); + entriesObserved++; + if (entriesObserved > maxEntries) + return null; + + exactNames.Add(Path.GetFileName(entry)); + } + + foreach (var entryName in exactNames) { + cancellationToken.ThrowIfCancellationRequested(); + var normalizedEntry = Path.Combine(normalizedDirectory, entryName); if (!TryCreateLeafNameCaseVariant(normalizedEntry, out var variant)) continue; diff --git a/src/CodeIndex/Indexer/Scanning/FileIndexer.CaseSensitivity.cs b/src/CodeIndex/Indexer/Scanning/FileIndexer.CaseSensitivity.cs index e3b0019ac..499fc72a9 100644 --- a/src/CodeIndex/Indexer/Scanning/FileIndexer.CaseSensitivity.cs +++ b/src/CodeIndex/Indexer/Scanning/FileIndexer.CaseSensitivity.cs @@ -52,12 +52,22 @@ or NotSupportedException } } - private static bool? ProbeExistingDirectoryIgnoreCase(string directory, IReadOnlyList entries) + private static bool? ProbeExistingDirectoryIgnoreCase( + string directory, + IReadOnlyList entries, + CancellationToken cancellationToken) { try { var normalizedDirectory = NormalizeDirectoryCaseProbePath(directory); - return CaseSensitivityProbeDirectory.ProbeExistingChildIgnoreCase(normalizedDirectory, entries); + return CaseSensitivityProbeDirectory.ProbeExistingChildIgnoreCase( + normalizedDirectory, + entries, + cancellationToken); + } + catch (OperationCanceledException) + { + throw; } catch { @@ -69,17 +79,22 @@ private static string NormalizeDirectoryCaseProbePath(string directory) => Path.IsPathFullyQualified(directory) ? directory : Path.GetFullPath(directory); private bool DirectoryUsesIgnoreCase(string directory) - => DirectoryUsesIgnoreCase(directory, entries: null); + => DirectoryUsesIgnoreCase(directory, entries: null, CancellationToken.None); - private bool DirectoryUsesIgnoreCase(string directory, IReadOnlyList? entries) + private bool DirectoryUsesIgnoreCase( + string directory, + IReadOnlyList? entries, + CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); var fullPath = NormalizeDirectoryCaseProbePath(directory); if (_directoryIgnoreCaseCache.TryGetValue(fullPath, out var ignoreCase)) return ignoreCase; var probeResult = _usesDefaultDirectoryIgnoreCaseProbe && entries is not null - ? ProbeExistingDirectoryIgnoreCase(fullPath, entries) + ? ProbeExistingDirectoryIgnoreCase(fullPath, entries, cancellationToken) : _directoryIgnoreCaseProbe(fullPath); + cancellationToken.ThrowIfCancellationRequested(); ignoreCase = probeResult ?? _ignoreCase; _directoryIgnoreCaseCache[fullPath] = ignoreCase; return ignoreCase; diff --git a/src/CodeIndex/Indexer/Scanning/FileIndexer.DirectoryEnumeration.cs b/src/CodeIndex/Indexer/Scanning/FileIndexer.DirectoryEnumeration.cs index aaacef8d5..5baa6e2c2 100644 --- a/src/CodeIndex/Indexer/Scanning/FileIndexer.DirectoryEnumeration.cs +++ b/src/CodeIndex/Indexer/Scanning/FileIndexer.DirectoryEnumeration.cs @@ -77,14 +77,14 @@ private bool EnumerateDirectory( if (_usesDefaultDirectoryIgnoreCaseProbe) { entries = MaterializeDirectoryEntries(dir, cancellationToken); - directoryIgnoreCase = DirectoryUsesIgnoreCase(dir, entries); + directoryIgnoreCase = DirectoryUsesIgnoreCase(dir, entries, cancellationToken); } else { // Preserve the custom-probe contract, including one invocation for a directory // whose subsequent entry enumeration fails. // custom probe は後続の entry 列挙が失敗する directory でも従来どおり1回呼ぶ。 - directoryIgnoreCase = DirectoryUsesIgnoreCase(dir); + directoryIgnoreCase = DirectoryUsesIgnoreCase(dir, entries: null, cancellationToken); entries = MaterializeDirectoryEntries(dir, cancellationToken); } RecordDirectoryCaseSensitivityWarning(relativeDir, directoryIgnoreCase, scanState); @@ -101,7 +101,7 @@ private bool EnumerateDirectory( } else { - var directoryIgnoreCase = DirectoryUsesIgnoreCase(dir); + var directoryIgnoreCase = DirectoryUsesIgnoreCase(dir, entries: null, cancellationToken); RecordDirectoryCaseSensitivityWarning(relativeDir, directoryIgnoreCase, scanState); if (!passthrough) EnumerateIndexableFilesInDirectory(dir, scanState, activeIgnoreRules, directoryIgnoreCase, cancellationToken); diff --git a/tests/CodeIndex.Tests/PathCompatibilityMatrixTests.cs b/tests/CodeIndex.Tests/PathCompatibilityMatrixTests.cs index 2fe4ae678..c88acb409 100644 --- a/tests/CodeIndex.Tests/PathCompatibilityMatrixTests.cs +++ b/tests/CodeIndex.Tests/PathCompatibilityMatrixTests.cs @@ -132,18 +132,78 @@ public void CaseProbeMatrix_ExistingChildProbeIgnoresParentSiblingCaseCollision_ } [Fact] - public void CaseProbeMatrix_SnapshotOverloadMatchesLegacyEnumeration() + public void CaseProbeMatrix_SnapshotIsBoundedAndCancelable_Issue5160() { using var workspace = MatrixWorkspace.Create("cdidx_case_probe_snapshot"); var target = workspace.FullPath("target"); Directory.CreateDirectory(target); File.WriteAllText(Path.Combine(target, "dockerfile"), "FROM scratch\n"); + File.WriteAllText(Path.Combine(target, "makefile"), "all:\n\t@true\n"); var entries = Directory.EnumerateFileSystemEntries(target).ToArray(); + using var canceledWriteProbe = new CancellationTokenSource(); + canceledWriteProbe.Cancel(); + var canceledWriteException = Assert.Throws(() => + CaseSensitivityProbeDirectory.ProbeIgnoreCase( + workspace.Root, + "case-probe-test-", + canceledWriteProbe.Token)); + Assert.Equal(canceledWriteProbe.Token, canceledWriteException.CancellationToken); + Assert.Empty(Directory.GetDirectories( + workspace.Root, + $"{CaseSensitivityProbeDirectory.IsolatedProbeDirectoryPrefix}*", + SearchOption.TopDirectoryOnly)); + var legacyResult = CaseSensitivityProbeDirectory.ProbeExistingChildIgnoreCase(target); - var snapshotResult = CaseSensitivityProbeDirectory.ProbeExistingChildIgnoreCase(target, entries); + var snapshotResult = CaseSensitivityProbeDirectory.ProbeExistingChildIgnoreCase( + target, + entries, + maxEntries: entries.Length); Assert.Equal(legacyResult, snapshotResult); + Assert.Null(CaseSensitivityProbeDirectory.ProbeExistingChildIgnoreCase(target, maxEntries: 1)); + + var entriesObserved = 0; + IEnumerable CountedEntries() + { + foreach (var entry in entries) + { + entriesObserved++; + yield return entry; + } + } + + Assert.Null(CaseSensitivityProbeDirectory.ProbeExistingChildIgnoreCase( + target, + CountedEntries(), + maxEntries: 1)); + Assert.Equal(2, entriesObserved); + + using var preCanceled = new CancellationTokenSource(); + preCanceled.Cancel(); + var preCanceledException = Assert.Throws(() => + CaseSensitivityProbeDirectory.ProbeExistingChildIgnoreCase( + target, + entries, + preCanceled.Token, + entries.Length)); + Assert.Equal(preCanceled.Token, preCanceledException.CancellationToken); + + using var canceledDuringEnumeration = new CancellationTokenSource(); + IEnumerable CancelingEntries() + { + yield return entries[0]; + canceledDuringEnumeration.Cancel(); + yield return entries[1]; + } + + var midEnumerationException = Assert.Throws(() => + CaseSensitivityProbeDirectory.ProbeExistingChildIgnoreCase( + target, + CancelingEntries(), + canceledDuringEnumeration.Token, + entries.Length)); + Assert.Equal(canceledDuringEnumeration.Token, midEnumerationException.CancellationToken); } public static TheoryData LongPathCases()