diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 8d9b2eb29..53e81adc1 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -890,6 +890,7 @@ For background log or metrics sinks, use explicit writer-entry/release signals a - Detailed database-diff coverage must keep record materialization within the requested JSON budget plus at most one candidate record, stop only at complete UTF-8 record boundaries, reject cursors after the deterministic difference sequence changes, and withhold continuations that cannot advance. Assert recovery guidance for zero-limit and first-record-too-large pages, suppress unusable cursor/replay metadata in embedded import dry-run comparisons, and preserve valid `--offset` continuation for non-detailed JSON and detailed text output. - Keep the `files` / `map` `--exclude-tests` cross-command invariant in one seeded fixture: compare `map.file_count` with the parsed `files` row count for both the implicit production-source preset and an explicit `--path`. - JSON failure-contract tests must assert that stdout contains one parseable versioned error object, stderr is empty, the documented exit code is preserved, and `status` / `error_code` identify the failure. +- Structural search-guard coverage must pair accepted evidence with unsafe controls in the same focused fixture. For whole-file reads, keep same-path size/control guards and resolved awaited bounded writers alongside unguarded reads, unrelated or reassigned paths, conditional size sources, inverted checks, unawaited helpers, out-of-order named arguments, and helpers in non-dominating switch sections. For traversal, pass inline, local, parameter, same-type, and reordered named `EnumerationOptions` to the actual `Directory.Enumerate*` call while retaining missing, unrelated, and shadowed-option controls. Assert accepted and rejected decision/reason/relationship/subject/container/evidence-path metadata rather than relying on source-line proximity. - Auth-token recipe ranking coverage must pair high-signal credential fixtures with comment, regex-definition, and structural `CancellationToken` / syntax-token decoys, then assert explicit top-N precision for the bearer/authorization, GitHub/API/access-token, and token-secret child queries. - `inspect` file-coordinate coverage must prove that an exact indexed positional path wins over symbol lookup and that missing paths or out-of-range lines fail before nearest-symbol resolution. - For bounded NDJSON streams, include plain and recipe/audit search paths, parse the final `terminal_record`, verify authoritative-versus-lower-bound totals (including per-query and total-limit omissions) and selection reasons/counts before result-limit trimming, count UTF-8 bytes including newlines, and assert partial exit `11` plus the explicit `--allow-partial` exit-`0` opt-in. Also assert that capped profile/verbose/envelope combinations fail before stdout. @@ -1828,6 +1829,7 @@ background の log / metrics sink は、sleep や狭い stopwatch 閾値では - database diff の category coverage では、semantic に同等な1組の database を、volatile な timestamp / duration / mode / byte counter の drift、readiness/provenance drift、既定の semantic mode、`--data-only`、`--include-telemetry` で再利用する。判定対象に含む mode と除外する mode の両方で stable な category / reason code を検証し、content、graph、schema、legacy metadata の assertion は最も近い既存 fixture に維持する。 - `files` / `map` の `--exclude-tests` に関する cross-command invariant は、1 つの seed 済み fixture で維持する。暗黙の本番ソース preset と明示的な `--path` の両方について、`map.file_count` と parse 済み `files` row 数を比較する。 - JSON failure contract のテストでは、stdout に parse 可能な version 付き error object が 1 件だけあること、stderr が空であること、documented exit code が維持されること、`status` / `error_code` が失敗を識別することを検証する。 +- 構造的な search guard の coverage では、同じ focused fixture 内で採用される evidence と unsafe control を対にしてください。whole-file read では、同一 path の size / control guard と解決済みかつ await 済みの bounded writer に加え、unguarded read、無関係または再代入された path、条件付き size source、反転 check、await されない helper、順序を入れ替えた named argument、支配しない switch section 内の helper を維持します。traversal では inline、local、parameter、同じ型、および順序を入れ替えた named `EnumerationOptions` を実際の `Directory.Enumerate*` 呼び出しへ渡し、options 未指定/無関係/shadowing の control も残します。source line の近接性ではなく、採用/棄却の decision、reason、relationship、subject、container、evidence path metadata を検証してください。 - auth-token recipe の ranking coverage では、高 signal な credential fixture と comment、regex 定義、構造的な `CancellationToken` / syntax-token decoy を対にし、bearer / authorization、GitHub / API / access-token、token-secret の各 child query について明示的な top-N precision を検証する。 - `inspect` の file-coordinate coverage では、indexed positional path の完全一致が symbol lookup より優先されることと、path 未検出・line 範囲外が nearest-symbol 解決前に失敗することを検証する。 - 上限付き NDJSON stream は plain search と recipe / audit search の両経路を含め、最後の `terminal_record` を解析し、query ごとおよび total-limit の省略を含む authoritative / lower-bound の総件数、result-limit 適用前の selection 理由 / 件数、改行を含む UTF-8 byte 数、partial 終了コード `11`、明示的な `--allow-partial` による終了コード `0` の opt-in を検証する。上限付き profile / verbose / envelope の組み合わせが stdout 前に失敗することも確認する。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 74b0ca22a..c08c2773f 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1377,8 +1377,16 @@ limit before/after checks to the same source line and the primary match column ordering instead of nearby lines. JSON search results include `guard_evidence` for matched guards and `guard_checks` for each guard evaluated on a returned match. Guard evidence includes the guard name, pattern, -before/after relationship, scope (`window` or `same_line`), 1-based span, -origin category, and source line. +before/after relationship, scope (`window`, `same_line`, or the recipe-only +`container` scope), 1-based span, origin category, and source line. Built-in +whole-file-read and filesystem-traversal recipes use bounded C# structural +checks instead of line proximity: they correlate the same path through a +size/control guard or resolved bounded writer, and resolve the +`EnumerationOptions` value actually passed to `Directory.Enumerate*`. +Structural `guard_evidence` also reports `decision`, `reason`, `subject`, +`container`, and `evidence_path`; `guard_checks[].rejected_evidence` explains +why unrelated paths, inverted checks, unawaited helpers, or missing/unrelated +options were not accepted as guards. Each `guard_checks[]` entry includes a compact pass/fail summary. Guarded searches inspect a bounded candidate set before pagination; if a guarded query is too broad to satisfy the requested page within that budget, CLI and MCP @@ -4750,7 +4758,14 @@ guard-aware search は primary の `search` 一致を近傍の literal guard で before / after を評価します。JSON の検索結果には 一致した guard の `guard_evidence` と、返却された一致に対して評価した各 guard の `guard_checks` が含まれます。guard evidence には guard 名、pattern、before/after の関係、 -scope(`window` または `same_line`)、1-based span、origin category、ソース行、簡潔な pass/fail summary が入ります。 +scope(`window`、`same_line`、または recipe 専用の `container`)、1-based span、 +origin category、ソース行、簡潔な pass/fail summary が入ります。組み込みの whole-file-read と +filesystem-traversal recipe は行の近接性ではなく、上限付きの C# 構造判定を使います。同じ path の +size / control guard または解決済み bounded writer を関連付け、`Directory.Enumerate*` に実際に +渡された `EnumerationOptions` 値を解決します。構造的な `guard_evidence` はさらに +`decision`、`reason`、`subject`、`container`、`evidence_path` を返し、 +`guard_checks[].rejected_evidence` は無関係な path、反転した条件、await されない helper、 +未指定または無関係な options が guard として採用されなかった理由を説明します。 guard filter を使う検索は pagination 前に上限付きの候補集合だけを調べます。その budget 内で 要求ページを満たせないほど query が広い場合、CLI/MCP は validation error を返します。 このエラーには guard budget、sampled candidate files / languages、`--count` / `--count-by` diff --git a/changelog.d/unreleased/4912.fixed.md b/changelog.d/unreleased/4912.fixed.md new file mode 100644 index 000000000..b0077b7bf --- /dev/null +++ b/changelog.d/unreleased/4912.fixed.md @@ -0,0 +1,25 @@ +--- +category: fixed +issues: + - 4912 +affected: + - src/CodeIndex/Models/QueryResults.cs + - src/CodeIndex/Database/DbSearchReader.cs + - src/CodeIndex/Database/DbSearchReader.StructuralGuards.cs + - src/CodeIndex/Cli/SearchAuditRecipes.cs + - src/CodeIndex/Cli/QueryCommandRunner.ArgParsing.cs + - src/CodeIndex/Cli/QueryCommandRunner.ResultEnvelopes.cs + - src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchGuardTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs + - USER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Safety-audit guards now follow structural evidence instead of fixed line proximity (#4912)** — whole-file-read recipes accept same-path size/control guards and resolved bounded-writer helpers, while filesystem-traversal recipes resolve the `EnumerationOptions` value actually passed by the call. Accepted and rejected candidates now include decision, reason, relationship, subject, container, and evidence-path metadata, so unrelated variables, inverted checks, missing options, and unawaited helpers remain actionable findings. + +## 日本語 + +- **安全監査のガード判定が固定行の近接性ではなく構造的証拠を追跡するようになりました (#4912)** — whole-file-read レシピは同一路径のサイズ/制御ガードと解決済みの境界付き writer helper を採用し、filesystem-traversal レシピは呼び出しに実際に渡された `EnumerationOptions` 値を解決します。採用/棄却候補には decision、reason、relationship、subject、container、evidence path のメタデータが含まれ、無関係な変数、反転条件、options 未指定、await されない helper は引き続き対応対象として検出されます。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgParsing.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgParsing.cs index cb9dc1b0a..a2153f9e2 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgParsing.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgParsing.cs @@ -41,7 +41,12 @@ private static bool TryNormalizeSearchGuardScope(string value, out SearchGuardSc } private static string FormatSearchGuardScope(SearchGuardScope scope) - => scope == SearchGuardScope.SameLine ? "same-line" : "window"; + => scope switch + { + SearchGuardScope.SameLine => "same-line", + SearchGuardScope.Container => "container", + _ => "window", + }; public static QueryCommandOptions ParseArgs( string[] args, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ResultEnvelopes.cs b/src/CodeIndex/Cli/QueryCommandRunner.ResultEnvelopes.cs index 8cc7b2ba3..ddf86ad8e 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ResultEnvelopes.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ResultEnvelopes.cs @@ -369,6 +369,13 @@ private static JsonArray BuildSearchGuardFiltersJson(IReadOnlyList "csharp_bounded_file_read", + SearchGuardEvidenceKind.CSharpEnumerationOptions => "csharp_enumeration_options", + _ => "text", + }; filters.Add(item); } diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs index 9cbeeaa51..d9ebd3a94 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs @@ -3891,7 +3891,19 @@ private static List ToSearchRecipeGuardFilter filter.Direction == SearchGuardDirection.Before ? "before" : "after", filter.Query, BuildSearchGuardReplayOptionName(filter), - FormatSearchGuardFilterScope(filter))) + filter.Scope switch + { + SearchGuardScope.Window => "window", + SearchGuardScope.SameLine => "same_line", + SearchGuardScope.Container => "container", + _ => null, + }, + filter.EvidenceKind switch + { + SearchGuardEvidenceKind.CSharpBoundedFileRead => "csharp_bounded_file_read", + SearchGuardEvidenceKind.CSharpEnumerationOptions => "csharp_enumeration_options", + _ => null, + })) .ToList(); } diff --git a/src/CodeIndex/Cli/SearchAuditRecipes.cs b/src/CodeIndex/Cli/SearchAuditRecipes.cs index 9d866dca6..96531c157 100644 --- a/src/CodeIndex/Cli/SearchAuditRecipes.cs +++ b/src/CodeIndex/Cli/SearchAuditRecipes.cs @@ -211,14 +211,14 @@ internal static class SearchAuditRecipes "Classify the timestamp boundary first. Persisted and machine-facing values need explicit UTC or offset semantics; cache expiry must compare like-with-like clocks; elapsed-time and timeout logic should use monotonic duration primitives rather than DateTime wall-clock subtraction."); private static readonly SearchRecipeClassifierJsonResult GuardEvidenceClassifier = new( "guard_evidence", - "Classifies whether nearby guard checks explain why a risky API call is already bounded, filtered, or intentionally rejected.", + "Classifies whether guard checks structurally explain why a risky API call is already bounded, filtered, or intentionally rejected.", [ - new("bounded_positive_evidence", "A required guard appears near the primary match.", "Use guard_evidence and guard_checks to decide whether the hit is already bounded."), - new("missing_guard", "No required guard was found near the primary match.", "Prioritize review when the query describes an API that needs bounds or policy."), + new("bounded_positive_evidence", "A required guard is structurally tied to the primary match.", "Use guard_evidence and guard_checks to decide whether the hit is already bounded."), + new("missing_guard", "No applicable required guard was found for the primary match.", "Prioritize review when the query describes an API that needs bounds or policy."), new("reject_guard_excluded", "A reject guard intentionally removed an otherwise noisy hit.", "Use --show-excluded or a narrower child query when auditing recipe precision.") ], ["guard_filters", "guard_evidence", "guard_checks", "risk_evidence"], - "Guard evidence is query-local context, not proof of safety; verify that the guard applies to the matched operation and not an unrelated nearby call."); + "Guard evidence is diagnostic context, not proof of safety; verify accepted/rejected reasons, subject, container, relationship, and evidence path for the matched operation."); private static readonly SearchRecipeClassifierJsonResult SecretOriginClassifier = new( "secret_origin", "Classifies token/auth hits by likely sensitive runtime material versus structural, SQL, protocol, docs, or placeholder text.", @@ -570,7 +570,18 @@ private static SearchAuditRecipeQuery TimestampBoundaryQuery( "File.ReadAllText", "Find whole-file text reads that may need size caps, sharing policy, or streaming alternatives.", ["audit", "performance"], - "False positives include bounded test fixtures and small files guarded by explicit size checks."), + "False positives include bounded test fixtures and small files guarded by explicit size checks.") + { + GuardFilters = + [ + new( + SearchGuardRole.Reject, + SearchGuardDirection.Before, + "bounded-file-read", + SearchGuardScope.Container, + SearchGuardEvidenceKind.CSharpBoundedFileRead) + ], + }, new( "file-read-all-bytes", "File.ReadAllBytes", @@ -2719,19 +2730,23 @@ private static SearchAuditRecipeQuery TimestampBoundaryQuery( new( "enumerate-without-options", "Directory.Enumerate", - "Find direct Directory.Enumerate* calls that do not have nearby EnumerationOptions evidence and may need traversal policy review.", + "Find direct Directory.Enumerate* calls that do not pass structurally resolved EnumerationOptions evidence and may need traversal policy review.", ["audit", "performance", "security"], "False positives include known-small directories, already-budgeted traversal helpers, and wrappers that enforce cancellation or reparse-point policy.") { RiskEvidence = [ - "risk: direct Directory.Enumerate* calls without nearby EnumerationOptions can inherit default recursion, inaccessible-path, and reparse-point behavior.", - "positive: known-small directories, cancellation/budget checks, and shared traversal wrappers can explain intentional direct enumeration." + "risk: direct Directory.Enumerate* calls without a resolved EnumerationOptions argument can inherit default recursion, inaccessible-path, and reparse-point behavior.", + "positive: an inline, local, parameter, or same-container EnumerationOptions value passed to that call explains intentional direct enumeration." ], GuardFilters = [ - new(SearchGuardRole.Reject, SearchGuardDirection.Before, "EnumerationOptions"), - new(SearchGuardRole.Reject, SearchGuardDirection.After, "EnumerationOptions") + new( + SearchGuardRole.Reject, + SearchGuardDirection.Before, + "configured-enumeration-options", + SearchGuardScope.Container, + SearchGuardEvidenceKind.CSharpEnumerationOptions) ], MatchOrigins = ["code"], }, @@ -3534,7 +3549,16 @@ private static SearchAuditRecipeQuery TimestampBoundaryQuery( RiskEvidence = [ "risk: whole-file text reads can materialize unbounded input without sharing or size policy.", - "positive: nearby length checks, BoundedFile helpers, or tiny trusted files can make a hit intentional." + "positive: same-path size/control checks, resolved bounded-writer helpers, or tiny trusted files can make a hit intentional." + ], + GuardFilters = + [ + new( + SearchGuardRole.Reject, + SearchGuardDirection.Before, + "bounded-file-read", + SearchGuardScope.Container, + SearchGuardEvidenceKind.CSharpBoundedFileRead) ], }, new( @@ -4415,7 +4439,10 @@ internal sealed record SearchRecipeGuardFilterJsonResult( [property: JsonPropertyName("option")] string Option, [property: JsonPropertyName("scope")] [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - string? Scope); + string? Scope, + [property: JsonPropertyName("evidence_kind")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + string? EvidenceKind); internal sealed record SearchRecipeClassifierJsonResult( [property: JsonPropertyName("name")] string Name, diff --git a/src/CodeIndex/Database/DbSearchReader.StructuralGuards.cs b/src/CodeIndex/Database/DbSearchReader.StructuralGuards.cs new file mode 100644 index 000000000..e8cec4b4c --- /dev/null +++ b/src/CodeIndex/Database/DbSearchReader.StructuralGuards.cs @@ -0,0 +1,1692 @@ +using System.Text; + +namespace CodeIndex.Database; + +public partial class DbReader +{ + private const int MaxStructuralGuardEvidenceCandidates = 8; + private const int MaxStructuralInvocationLines = 32; + + private SearchGuardEvaluation FindStructuralGuardEvidence( + string path, + SearchPrimaryMatch primaryMatch, + SearchGuardFilter filter, + string? lang, + Dictionary> lineWindowCache) + { + if (!string.Equals(lang, "csharp", StringComparison.OrdinalIgnoreCase)) + { + var rejected = CreateStructuralEvidence( + path, + primaryMatch.LineNumber, + primaryMatch.Text, + filter, + "rejected", + "structural guard evidence is only available for C# source", + "language_mismatch", + subject: null, + container: null, + lang); + return new SearchGuardEvaluation(primaryMatch.LineNumber, primaryMatch.LineNumber, null, [rejected]); + } + + var container = FindStructuralGuardContainer(path, primaryMatch.LineNumber); + if (container == null) + { + var rejected = CreateStructuralEvidence( + path, + primaryMatch.LineNumber, + primaryMatch.Text, + filter, + "rejected", + "the primary match is not inside an indexed executable container", + "container_unresolved", + subject: null, + container: null, + lang); + return new SearchGuardEvaluation(primaryMatch.LineNumber, primaryMatch.LineNumber, null, [rejected]); + } + + var lines = ReadLineWindow(path, container.StartLine, container.EndLine, lineWindowCache); + return filter.EvidenceKind switch + { + SearchGuardEvidenceKind.CSharpBoundedFileRead => FindBoundedFileReadEvidence( + path, + primaryMatch, + filter, + lang, + container, + lines, + lineWindowCache), + SearchGuardEvidenceKind.CSharpEnumerationOptions => FindEnumerationOptionsEvidence( + path, + primaryMatch, + filter, + lang, + container, + lines, + lineWindowCache), + _ => new SearchGuardEvaluation(container.StartLine, container.EndLine, null), + }; + } + + private SearchGuardEvaluation FindBoundedFileReadEvidence( + string path, + SearchPrimaryMatch primaryMatch, + SearchGuardFilter filter, + string? lang, + SearchGuardContainer container, + SortedDictionary lines, + Dictionary> lineWindowCache) + { + var rejected = new List(); + var invocation = ExtractInvocation(lines, primaryMatch.LineNumber, "ReadAllText"); + if (invocation == null || invocation.Arguments.Count == 0) + { + AddRejectedStructuralEvidence( + rejected, + path, + primaryMatch.LineNumber, + primaryMatch.Text, + filter, + "the ReadAllText path argument could not be resolved", + "read_path_unresolved", + null, + container.Name, + lang); + return new SearchGuardEvaluation(container.StartLine, container.EndLine, null, rejected); + } + + var readPathArgument = FindNamedOrPositionalArgument(invocation.Arguments, "path", 0); + if (readPathArgument == null) + return new SearchGuardEvaluation(container.StartLine, container.EndLine, null, rejected); + var readPath = NormalizeCSharpExpression(RemoveNamedArgumentPrefix(readPathArgument)); + if (!IsSimpleCSharpValueExpression(readPath)) + { + AddRejectedStructuralEvidence( + rejected, + path, + primaryMatch.LineNumber, + primaryMatch.Text, + filter, + "the ReadAllText path is not a stable identifier or member access", + "read_path_not_stable", + readPath, + container.Name, + lang); + return new SearchGuardEvaluation(container.StartLine, container.EndLine, null, rejected); + } + + var sizeSources = FindFileSizeSources( + path, + lines, + container, + primaryMatch.LineNumber, + readPath, + filter, + lang, + rejected); + foreach (var source in sizeSources) + { + foreach (var (lineNumber, text) in lines) + { + if (lineNumber < source.Line || lineNumber >= primaryMatch.LineNumber || + !text.Contains("if", StringComparison.Ordinal)) + continue; + + var condition = ExtractCondition(lines, lineNumber); + if (condition == null || !ConditionReferencesSizeSource(condition.Value.Text, source.Expression)) + continue; + + if (source.Alias != null && HasAssignmentBetween(lines, source.Alias, source.Line + 1, lineNumber - 1)) + { + AddRejectedStructuralEvidence( + rejected, + path, + lineNumber, + text, + filter, + $"size alias '{source.Alias}' is reassigned before the guard", + "size_alias_reassigned", + readPath, + container.Name, + lang); + continue; + } + + if (HasAssignmentBetween(lines, readPath, source.Line + 1, primaryMatch.LineNumber - 1)) + { + AddRejectedStructuralEvidence( + rejected, + path, + lineNumber, + text, + filter, + $"read path '{readPath}' is reassigned after the size source and before ReadAllText", + "read_path_reassigned", + readPath, + container.Name, + lang); + continue; + } + + if (IsRejectingUpperBound(condition.Value.Text, source.Expression) && + GuardBranchTerminates(lines, lineNumber, primaryMatch.LineNumber)) + { + return new SearchGuardEvaluation( + container.StartLine, + container.EndLine, + CreateStructuralEvidence( + path, + lineNumber, + text, + filter, + "accepted", + "the same ReadAllText path is size-checked and the oversized branch terminates before the read", + "same_path_size_guard", + readPath, + container.Name, + lang), + rejected.Count == 0 ? null : rejected); + } + + if (IsAcceptingUpperBound(condition.Value.Text, source.Expression) && + IsLineInsideGuardBranch(lines, lineNumber, primaryMatch.LineNumber)) + { + return new SearchGuardEvaluation( + container.StartLine, + container.EndLine, + CreateStructuralEvidence( + path, + lineNumber, + text, + filter, + "accepted", + "the same ReadAllText path is read only inside the bounded-size branch", + "same_path_control_guard", + readPath, + container.Name, + lang), + rejected.Count == 0 ? null : rejected); + } + + AddRejectedStructuralEvidence( + rejected, + path, + lineNumber, + text, + filter, + "the size comparison is inverted or does not terminate/control the path to ReadAllText", + "size_guard_control_path_rejected", + readPath, + container.Name, + lang); + } + } + + var helperEvidence = FindResolvedBoundedWriterEvidence( + path, + primaryMatch, + filter, + lang, + container, + lines, + readPath, + rejected, + lineWindowCache); + if (helperEvidence != null) + return new SearchGuardEvaluation(container.StartLine, container.EndLine, helperEvidence, rejected.Count == 0 ? null : rejected); + + if (rejected.Count == 0) + { + AddRejectedStructuralEvidence( + rejected, + path, + primaryMatch.LineNumber, + primaryMatch.Text, + filter, + "no same-path size guard or resolved bounded writer reaches this ReadAllText call", + "bounded_read_evidence_missing", + readPath, + container.Name, + lang); + } + + return new SearchGuardEvaluation(container.StartLine, container.EndLine, null, rejected); + } + + private SearchGuardEvaluation FindEnumerationOptionsEvidence( + string path, + SearchPrimaryMatch primaryMatch, + SearchGuardFilter filter, + string? lang, + SearchGuardContainer container, + SortedDictionary lines, + Dictionary> lineWindowCache) + { + var rejected = new List(); + var invocation = ExtractInvocation(lines, primaryMatch.LineNumber, "Directory.Enumerate"); + if (invocation == null || invocation.Arguments.Count < 3) + { + AddRejectedStructuralEvidence( + rejected, + path, + primaryMatch.LineNumber, + primaryMatch.Text, + filter, + "the Directory.Enumerate* call does not pass an EnumerationOptions argument", + "enumeration_options_argument_missing", + null, + container.Name, + lang); + return new SearchGuardEvaluation(container.StartLine, container.EndLine, null, rejected); + } + + var optionsArgument = FindNamedOrPositionalArgument( + invocation.Arguments, + "enumerationOptions", + invocation.Arguments.Count - 1); + if (optionsArgument == null) + return new SearchGuardEvaluation(container.StartLine, container.EndLine, null, rejected); + var optionsExpression = RemoveNamedArgumentPrefix(optionsArgument); + var normalizedOptions = NormalizeCSharpExpression(optionsExpression); + if (normalizedOptions.StartsWith("newEnumerationOptions", StringComparison.Ordinal) || + normalizedOptions.StartsWith("new()", StringComparison.Ordinal)) + { + return new SearchGuardEvaluation( + container.StartLine, + container.EndLine, + CreateStructuralEvidence( + path, + primaryMatch.LineNumber, + primaryMatch.Text, + filter, + "accepted", + "the enumeration call receives an inline EnumerationOptions value", + "same_call_options_argument", + normalizedOptions, + container.Name, + lang)); + } + + if (!IsSimpleCSharpValueExpression(normalizedOptions)) + { + AddRejectedStructuralEvidence( + rejected, + path, + primaryMatch.LineNumber, + primaryMatch.Text, + filter, + "the final enumeration argument is not a resolvable local or same-container options expression", + "enumeration_options_symbol_unresolved", + normalizedOptions, + container.Name, + lang); + return new SearchGuardEvaluation(container.StartLine, container.EndLine, null, rejected); + } + + var optionsParts = normalizedOptions.Split('.'); + var optionsName = optionsParts[^1]; + if (optionsParts.Length > 1 && !IsSameContainerReceiver(optionsParts[..^1], container.ContainerName)) + { + AddRejectedStructuralEvidence( + rejected, + path, + primaryMatch.LineNumber, + primaryMatch.Text, + filter, + $"the EnumerationOptions receiver in '{normalizedOptions}' is not the current type/container", + "enumeration_options_receiver_rejected", + normalizedOptions, + container.Name, + lang); + return new SearchGuardEvaluation(container.StartLine, container.EndLine, null, rejected); + } + + var definition = FindEnumerationOptionsDefinition( + path, + container, + primaryMatch.LineNumber, + optionsName, + lineWindowCache); + if (definition == null) + { + AddRejectedStructuralEvidence( + rejected, + path, + primaryMatch.LineNumber, + primaryMatch.Text, + filter, + $"'{normalizedOptions}' does not resolve to EnumerationOptions in the same executable/type container", + "enumeration_options_definition_rejected", + normalizedOptions, + container.Name, + lang); + return new SearchGuardEvaluation(container.StartLine, container.EndLine, null, rejected); + } + + return new SearchGuardEvaluation( + container.StartLine, + container.EndLine, + CreateStructuralEvidence( + path, + definition.Line, + definition.Text, + filter, + "accepted", + "the enumeration argument resolves to an EnumerationOptions definition in the same executable/type container", + "same_argument_options_definition", + normalizedOptions, + definition.Container, + lang)); + } + + private List FindFileSizeSources( + string path, + SortedDictionary lines, + SearchGuardContainer container, + int readLine, + string readPath, + SearchGuardFilter filter, + string? lang, + List rejected) + { + var sources = new List(); + foreach (var (lineNumber, text) in lines) + { + if (lineNumber >= readLine) + break; + + var codeLine = GetMaskedCSharpLine(lines, lineNumber); + var normalized = NormalizeCSharpExpression(codeLine); + var marker = "newFileInfo("; + var markerIndex = normalized.IndexOf(marker, StringComparison.Ordinal); + if (markerIndex < 0) + continue; + + var argumentStart = markerIndex + marker.Length; + var argumentEnd = FindMatchingDelimiter(normalized, argumentStart - 1, '(', ')'); + if (argumentEnd < argumentStart) + continue; + var hasInlineLength = normalized.AsSpan(argumentEnd).StartsWith(").Length", StringComparison.Ordinal); + var fileInfoExpression = $"newFileInfo({readPath})"; + + var candidatePath = normalized[argumentStart..argumentEnd]; + if (!string.Equals(candidatePath, readPath, StringComparison.Ordinal)) + { + AddRejectedStructuralEvidence( + rejected, + path, + lineNumber, + text, + filter, + "FileInfo.Length targets a different path than ReadAllText", + "different_path_size_source", + candidatePath, + container.Name, + lang); + continue; + } + + var assignmentIndex = normalized.IndexOf('='); + string? alias = null; + if (assignmentIndex >= 0 && !IsComparisonOperator(normalized, assignmentIndex)) + { + var rightHandSide = normalized[(assignmentIndex + 1)..].TrimEnd(';'); + var expectedRightHandSide = hasInlineLength ? fileInfoExpression + ".Length" : fileInfoExpression; + if (!string.Equals(rightHandSide, expectedRightHandSide, StringComparison.Ordinal)) + { + AddRejectedStructuralEvidence( + rejected, + path, + lineNumber, + text, + filter, + "FileInfo evidence is conditional or not a direct assignment from the read path", + "file_size_source_not_direct", + readPath, + container.Name, + lang); + continue; + } + var sourceAssignmentIndex = codeLine.IndexOf('='); + if (sourceAssignmentIndex >= 0) + alias = LastIdentifier(codeLine[..sourceAssignmentIndex]); + } + + if (alias == null && (!hasInlineLength || FindCSharpKeyword(normalized, "if") < 0)) + continue; + var expression = hasInlineLength + ? alias ?? fileInfoExpression + ".Length" + : alias + ".Length"; + sources.Add(new FileSizeSource(lineNumber, expression, alias)); + } + + return sources; + } + + private SearchGuardEvidence? FindResolvedBoundedWriterEvidence( + string path, + SearchPrimaryMatch primaryMatch, + SearchGuardFilter filter, + string? lang, + SearchGuardContainer container, + SortedDictionary callerLines, + string readPath, + List rejected, + Dictionary> lineWindowCache) + { + foreach (var call in FindResolvedCallsBefore(container.SymbolId, primaryMatch.LineNumber)) + { + var invocation = ExtractInvocation(callerLines, call.Line, call.Name); + if (invocation == null) + continue; + + var argumentIndex = invocation.Arguments.FindIndex(argument => + string.Equals(NormalizeCSharpExpression(RemoveNamedArgumentPrefix(argument)), readPath, StringComparison.Ordinal)); + if (argumentIndex < 0) + continue; + + if (!ResolvedCallDominatesRead(callerLines, call.Line, call.Name, primaryMatch.LineNumber)) + { + AddRejectedStructuralEvidence( + rejected, + path, + call.Line, + callerLines.GetValueOrDefault(call.Line, call.Name), + filter, + "the same-path helper call does not dominate the control-flow path to ReadAllText", + "bounded_writer_not_dominating", + readPath, + container.Name, + lang); + continue; + } + + if (IsTaskLike(call.ReturnType) && + !IsAwaitedOrSynchronouslyCompleted(invocation.Text)) + { + AddRejectedStructuralEvidence( + rejected, + path, + call.Line, + callerLines.GetValueOrDefault(call.Line, call.Name), + filter, + "the same-path helper is asynchronous but is not awaited before ReadAllText", + "bounded_writer_not_awaited", + readPath, + container.Name, + lang); + continue; + } + + var parameters = ParseParameterNames(call.Signature); + var targetParameter = ResolveInvocationParameterName(invocation.Arguments, argumentIndex, parameters); + if (targetParameter == null) + continue; + + var targetLines = ReadLineWindow(call.Path, call.StartLine, call.EndLine, lineWindowCache); + foreach (var (lineNumber, text) in targetLines) + { + var codeText = GetMaskedCSharpLine(targetLines, lineNumber); + var boundedMarkerIndex = FindContainerBodyMarkerIndex( + codeText, + lineNumber, + call.StartLine, + call.BodyStartLine, + "Bounded"); + if (boundedMarkerIndex < 0 || + !IsTopLevelContainerStatement(targetLines, call.StartLine, lineNumber, "Bounded")) + continue; + + var boundedInvocation = ExtractInvocation(targetLines, lineNumber, "Bounded", boundedMarkerIndex); + if (boundedInvocation == null) + continue; + var normalized = NormalizeCSharpExpression(boundedInvocation.Text); + if (!normalized.Contains(targetParameter, StringComparison.Ordinal) || + (!normalized.Contains("Write", StringComparison.Ordinal) && + !normalized.Contains("Copy", StringComparison.Ordinal) && + !normalized.Contains("Save", StringComparison.Ordinal) && + !normalized.Contains("Download", StringComparison.Ordinal)) || + !ContainsBoundToken(normalized)) + continue; + + return CreateStructuralEvidence( + call.Path, + lineNumber, + text, + filter, + "accepted", + $"resolved helper '{call.Name}' writes the same path through a bounded operation before ReadAllText", + "same_path_resolved_bounded_writer", + readPath, + call.Name, + lang); + } + + AddRejectedStructuralEvidence( + rejected, + path, + call.Line, + callerLines.GetValueOrDefault(call.Line, call.Name), + filter, + $"resolved helper '{call.Name}' receives the same path but has no bounded write contract", + "resolved_helper_not_bounded", + readPath, + container.Name, + lang); + } + + return null; + } + + private SearchGuardContainer? FindStructuralGuardContainer(string path, int line) + { + using var cmd = _conn.CreateCommand(); + cmd.CommandText = @" + SELECT s.id, s.name, s.container_name, s.start_line, s.end_line + FROM symbols s + JOIN files f ON f.id = s.file_id + WHERE f.path = @path + AND s.start_line <= @line + AND s.end_line >= @line + AND s.kind IN ('function', 'method', 'async_function', 'async_generator', 'generator', 'property', 'accessor', 'lambda') + ORDER BY (s.end_line - s.start_line) ASC, s.start_line DESC, s.id ASC + LIMIT 1;"; + SqliteCommandPolicy.Add(cmd, "@path", path); + SqliteCommandPolicy.Add(cmd, "@line", line); + using var reader = cmd.ExecuteTrackedReader(); + if (!reader.TrackedRead()) + return null; + + return new SearchGuardContainer( + reader.GetInt64(0), + reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetString(2), + reader.GetInt32(3), + reader.GetInt32(4)); + } + + private List FindResolvedCallsBefore(long sourceSymbolId, int line) + { + var calls = new List(); + using var cmd = _conn.CreateCommand(); + cmd.CommandText = @" + SELECT r.line, r.symbol_name, s.signature, s.return_type, + s.start_line, s.end_line, COALESCE(s.body_start_line, s.start_line), f.path + FROM symbol_references r + JOIN symbols s ON s.id = r.target_symbol_id + JOIN files f ON f.id = s.file_id + WHERE r.source_symbol_id = @sourceSymbolId + AND r.reference_kind = 'call' + AND r.line < @line + AND r.target_symbol_id IS NOT NULL + ORDER BY r.line DESC, r.id ASC;"; + SqliteCommandPolicy.Add(cmd, "@sourceSymbolId", sourceSymbolId); + SqliteCommandPolicy.Add(cmd, "@line", line); + using var reader = cmd.ExecuteTrackedReader(); + while (reader.TrackedRead()) + { + calls.Add(new ResolvedStructuralCall( + reader.GetInt32(0), + reader.GetString(1), + reader.IsDBNull(2) ? string.Empty : reader.GetString(2), + reader.IsDBNull(3) ? null : reader.GetString(3), + reader.GetInt32(4), + reader.GetInt32(5), + reader.GetInt32(6), + reader.GetString(7))); + } + + return calls; + } + + private EnumerationOptionsDefinition? FindEnumerationOptionsDefinition( + string path, + SearchGuardContainer container, + int primaryLine, + string name, + Dictionary> lineWindowCache) + { + var containerLines = ReadLineWindow(path, container.StartLine, Math.Min(container.EndLine, primaryLine), lineWindowCache); + var lexicalBinding = FindLexicalEnumerationOptionsBinding(containerLines, primaryLine, name); + if (lexicalBinding != null) + { + return lexicalBinding.IsEnumerationOptions + ? new EnumerationOptionsDefinition(lexicalBinding.Line, lexicalBinding.Text, container.Name) + : null; + } + + using var cmd = _conn.CreateCommand(); + cmd.CommandText = @" + SELECT s.line, s.start_line, s.end_line, s.return_type, s.signature, s.container_name + FROM symbols s + JOIN files f ON f.id = s.file_id + WHERE f.path = @path + AND s.name = @name COLLATE BINARY + AND ( + (s.start_line >= @containerStart + AND s.end_line <= @containerEnd + AND s.start_line <= @primaryLine) + OR s.container_name = @typeContainer COLLATE BINARY + ) + ORDER BY + CASE WHEN s.start_line >= @containerStart AND s.end_line <= @containerEnd THEN 0 ELSE 1 END, + s.start_line DESC, + s.id ASC;"; + SqliteCommandPolicy.Add(cmd, "@path", path); + SqliteCommandPolicy.Add(cmd, "@name", name); + SqliteCommandPolicy.Add(cmd, "@primaryLine", primaryLine); + SqliteCommandPolicy.Add(cmd, "@containerStart", container.StartLine); + SqliteCommandPolicy.Add(cmd, "@containerEnd", container.EndLine); + SqliteCommandPolicy.Add(cmd, "@typeContainer", container.ContainerName ?? string.Empty); + { + using var reader = cmd.ExecuteTrackedReader(); + while (reader.TrackedRead()) + { + var line = reader.GetInt32(0); + var startLine = reader.GetInt32(1); + var endLine = reader.GetInt32(2); + var returnType = reader.IsDBNull(3) ? null : reader.GetString(3); + var signature = reader.IsDBNull(4) ? string.Empty : reader.GetString(4); + var definitionLines = ReadLineWindow(path, startLine, endLine, lineWindowCache); + var text = definitionLines.GetValueOrDefault(line, signature); + var normalizedSignature = NormalizeCSharpExpression(MaskCSharpNonCode(signature)); + var explicitlyTyped = normalizedSignature.Contains("EnumerationOptions" + name, StringComparison.Ordinal); + var inferredType = normalizedSignature.Contains("var" + name + "=newEnumerationOptions", StringComparison.Ordinal); + if (!string.Equals(returnType, "EnumerationOptions", StringComparison.Ordinal) && !explicitlyTyped && !inferredType) + return null; + + return new EnumerationOptionsDefinition( + line, + text, + reader.IsDBNull(5) ? container.Name : reader.GetString(5)); + } + } + + return null; + } + + private static LexicalEnumerationOptionsBinding? FindLexicalEnumerationOptionsBinding( + SortedDictionary lines, + int primaryLine, + string name) + { + var callPath = GetStructuralBracePath(lines, primaryLine, "Directory.Enumerate"); + foreach (var (line, text) in lines.Reverse()) + { + var code = GetMaskedCSharpLine(lines, line); + var searchIndex = code.Length; + while (searchIndex > 0) + { + var nameIndex = code.LastIndexOf(name, searchIndex - 1, StringComparison.Ordinal); + if (nameIndex < 0) + break; + searchIndex = nameIndex; + var afterIndex = nameIndex + name.Length; + if ((nameIndex > 0 && IsCSharpIdentifierCharacter(code[nameIndex - 1])) || + (afterIndex < code.Length && IsCSharpIdentifierCharacter(code[afterIndex]))) + continue; + + var declarationPath = GetStructuralBracePath(lines, line, name); + if (declarationPath.Count > callPath.Count || + !declarationPath.SequenceEqual(callPath.Take(declarationPath.Count))) + continue; + + var typeEnd = nameIndex; + while (typeEnd > 0 && char.IsWhiteSpace(code[typeEnd - 1])) + typeEnd--; + if (typeEnd > 0 && code[typeEnd - 1] == '?') + { + typeEnd--; + while (typeEnd > 0 && char.IsWhiteSpace(code[typeEnd - 1])) + typeEnd--; + } + var typeStart = typeEnd; + while (typeStart > 0 && IsCSharpIdentifierCharacter(code[typeStart - 1])) + typeStart--; + if (typeStart == typeEnd) + continue; + + var after = NormalizeCSharpExpression(code[afterIndex..]); + if (after.Length == 0 || after[0] is not ('=' or ',' or ')' or ';')) + continue; + + var typeName = code[typeStart..typeEnd]; + if (string.Equals(typeName, "var", StringComparison.Ordinal)) + { + if (after[0] != '=') + continue; + return new LexicalEnumerationOptionsBinding( + after.StartsWith("=newEnumerationOptions", StringComparison.Ordinal), + line, + text); + } + + return new LexicalEnumerationOptionsBinding( + string.Equals(typeName, "EnumerationOptions", StringComparison.Ordinal), + line, + text); + } + } + return null; + } + + private static InvocationText? ExtractInvocation( + SortedDictionary lines, + int startLine, + string marker, + int minimumMarkerIndex = 0) + { + var text = JoinFollowingLines(lines, startLine, MaxStructuralInvocationLines); + var code = MaskCSharpNonCode(text); + var markerIndex = code.IndexOf(marker, Math.Max(0, minimumMarkerIndex), StringComparison.Ordinal); + if (markerIndex < 0) + return null; + + var openParen = code.IndexOf('(', markerIndex + marker.Length); + if (openParen < 0) + return null; + + var closeParen = FindMatchingDelimiter(code, openParen, '(', ')'); + if (closeParen < 0) + return null; + + var invocationEnd = closeParen + 1; + var tailLimit = Math.Min(text.Length, invocationEnd + 160); + var tail = code[invocationEnd..tailLimit]; + var statementEnd = tail.IndexOf(';'); + if (statementEnd >= 0) + invocationEnd += statementEnd + 1; + + return new InvocationText( + text[..Math.Min(text.Length, invocationEnd)], + SplitTopLevelArguments(text[(openParen + 1)..closeParen])); + } + + private static int FindContainerBodyMarkerIndex( + string codeLine, + int line, + int containerStartLine, + int bodyStartLine, + string marker) + { + if (line < bodyStartLine) + return -1; + var searchStart = 0; + if (line == containerStartLine || line == bodyStartLine) + { + var brace = codeLine.IndexOf('{'); + var arrow = codeLine.IndexOf("=>", StringComparison.Ordinal); + var delimiter = brace < 0 ? arrow : arrow < 0 ? brace : Math.Min(brace, arrow); + if (delimiter < 0) + return -1; + searchStart = delimiter + (delimiter == arrow ? 2 : 1); + } + return codeLine.IndexOf(marker, searchStart, StringComparison.Ordinal); + } + + private static string? FindNamedOrPositionalArgument( + IReadOnlyList arguments, + string parameterName, + int positionalIndex) + { + foreach (var argument in arguments) + { + if (string.Equals(GetNamedArgumentName(argument), parameterName, StringComparison.Ordinal)) + return argument; + } + return positionalIndex >= 0 && positionalIndex < arguments.Count + ? arguments[positionalIndex] + : null; + } + + private static string? ResolveInvocationParameterName( + IReadOnlyList arguments, + int argumentIndex, + IReadOnlyList parameters) + { + if (argumentIndex < 0 || argumentIndex >= arguments.Count) + return null; + var namedArgument = GetNamedArgumentName(arguments[argumentIndex]); + if (namedArgument != null) + return parameters.Contains(namedArgument, StringComparer.Ordinal) ? namedArgument : null; + return argumentIndex < parameters.Count ? parameters[argumentIndex] : null; + } + + private static string? GetNamedArgumentName(string expression) + { + var colon = expression.IndexOf(':'); + if (colon <= 0) + return null; + var candidate = NormalizeCSharpExpression(expression[..colon]); + return IsSimpleCSharpIdentifier(candidate) ? candidate : null; + } + + private static (string Text, int EndLine)? ExtractCondition( + SortedDictionary lines, + int startLine) + { + var text = MaskCSharpNonCode(JoinFollowingLines(lines, startLine, 12)); + var ifIndex = FindCSharpKeyword(text, "if"); + if (ifIndex < 0) + return null; + var openParen = text.IndexOf('(', ifIndex + 2); + if (openParen < 0) + return null; + var closeParen = FindMatchingDelimiter(text, openParen, '(', ')'); + if (closeParen < 0) + return null; + + var lineCount = text[..(closeParen + 1)].Count(ch => ch == '\n'); + return (text[(openParen + 1)..closeParen], startLine + lineCount); + } + + private static List SplitTopLevelArguments(string arguments) + { + var result = new List(); + var start = 0; + var paren = 0; + var bracket = 0; + var brace = 0; + var angle = 0; + var inString = false; + var stringDelimiter = '\0'; + var escaped = false; + for (var i = 0; i < arguments.Length; i++) + { + var ch = arguments[i]; + if (inString) + { + if (escaped) + { + escaped = false; + continue; + } + if (ch == '\\') + { + escaped = true; + continue; + } + if (ch == stringDelimiter) + inString = false; + continue; + } + + if (ch is '\'' or '"') + { + inString = true; + stringDelimiter = ch; + continue; + } + + switch (ch) + { + case '(': + paren++; + break; + case ')': + paren--; + break; + case '[': + bracket++; + break; + case ']': + bracket--; + break; + case '{': + brace++; + break; + case '}': + brace--; + break; + case '<': + angle++; + break; + case '>': + if (angle > 0) + angle--; + break; + case ',' when paren == 0 && bracket == 0 && brace == 0 && angle == 0: + result.Add(arguments[start..i].Trim()); + start = i + 1; + break; + } + } + + result.Add(arguments[start..].Trim()); + return result; + } + + private static int FindMatchingDelimiter(string text, int openIndex, char open, char close) + { + var depth = 0; + var inString = false; + var delimiter = '\0'; + var escaped = false; + for (var i = openIndex; i < text.Length; i++) + { + var ch = text[i]; + if (inString) + { + if (escaped) + { + escaped = false; + continue; + } + if (ch == '\\') + { + escaped = true; + continue; + } + if (ch == delimiter) + inString = false; + continue; + } + + if (ch is '\'' or '"') + { + inString = true; + delimiter = ch; + continue; + } + if (ch == open) + depth++; + else if (ch == close && --depth == 0) + return i; + } + + return -1; + } + + private static string JoinFollowingLines(SortedDictionary lines, int startLine, int maxLines) + { + var builder = new StringBuilder(); + var count = 0; + foreach (var (lineNumber, text) in lines) + { + if (lineNumber < startLine) + continue; + if (count++ >= maxLines) + break; + if (builder.Length > 0) + builder.Append('\n'); + builder.Append(text); + } + return builder.ToString(); + } + + private static string NormalizeCSharpExpression(string expression) + { + var builder = new StringBuilder(expression.Length); + foreach (var ch in expression) + { + if (!char.IsWhiteSpace(ch) && ch != '@') + builder.Append(ch); + } + return builder.ToString().Trim(';'); + } + + private static string RemoveNamedArgumentPrefix(string expression) + { + var colon = expression.IndexOf(':'); + if (colon <= 0 || expression[..colon].Any(ch => !char.IsLetterOrDigit(ch) && ch != '_' && ch != '@')) + return expression; + return expression[(colon + 1)..]; + } + + private static bool IsSimpleCSharpValueExpression(string expression) + => expression.Length > 0 && expression.Split('.').All(IsSimpleCSharpIdentifier); + + private static bool IsSimpleCSharpIdentifier(string identifier) + => identifier.Length > 0 && + (char.IsLetter(identifier[0]) || identifier[0] == '_') && + identifier.Skip(1).All(ch => char.IsLetterOrDigit(ch) || ch == '_'); + + private static bool IsSameContainerReceiver(string[] receiverParts, string? containerName) + { + if (receiverParts.Length == 1 && receiverParts[0] == "this") + return true; + if (string.IsNullOrWhiteSpace(containerName)) + return false; + + var containerParts = containerName.Split('.'); + return receiverParts.SequenceEqual(containerParts, StringComparer.Ordinal) || + (receiverParts.Length == 1 && + string.Equals(receiverParts[0], containerParts[^1], StringComparison.Ordinal)); + } + + private static string? LastIdentifier(string text) + { + var end = text.Length - 1; + while (end >= 0 && !char.IsLetterOrDigit(text[end]) && text[end] != '_') + end--; + if (end < 0) + return null; + var start = end; + while (start > 0 && (char.IsLetterOrDigit(text[start - 1]) || text[start - 1] == '_')) + start--; + var identifier = text[start..(end + 1)]; + return IsSimpleCSharpIdentifier(identifier) ? identifier : null; + } + + private static bool ConditionReferencesSizeSource(string condition, string source) + => NormalizeCSharpExpression(condition).Contains(source, StringComparison.Ordinal); + + private static bool IsRejectingUpperBound(string condition, string source) + { + var normalized = NormalizeCSharpExpression(condition); + if (normalized.Contains("&&", StringComparison.Ordinal)) + return false; + var index = normalized.IndexOf(source, StringComparison.Ordinal); + if (index < 0) + return false; + var suffix = normalized[(index + source.Length)..]; + var prefix = normalized[..index]; + return suffix.StartsWith('>') || prefix.EndsWith('<'); + } + + private static bool IsAcceptingUpperBound(string condition, string source) + { + var normalized = NormalizeCSharpExpression(condition); + if (normalized.Contains("||", StringComparison.Ordinal)) + return false; + var index = normalized.IndexOf(source, StringComparison.Ordinal); + if (index < 0) + return false; + var suffix = normalized[(index + source.Length)..]; + var prefix = normalized[..index]; + return suffix.StartsWith('<') || prefix.EndsWith('>'); + } + + private static bool GuardBranchTerminates( + SortedDictionary lines, + int ifLine, + int readLine) + { + var segment = BuildStructuralSourceSegment(lines, ifLine, Math.Min(readLine - 1, ifLine + 24)); + var code = MaskCSharpNonCode(segment.Text); + var ifIndex = FindCSharpKeyword(code, "if"); + if (ifIndex < 0) + return false; + var openParen = code.IndexOf('(', ifIndex + 2); + if (openParen < 0) + return false; + var closeParen = FindMatchingDelimiter(code, openParen, '(', ')'); + if (closeParen < 0) + return false; + + var bodyStart = SkipCSharpWhitespace(code, closeParen + 1); + if (bodyStart >= code.Length) + return false; + if (code[bodyStart] != '{') + { + var statementEnd = FindTopLevelStatementEnd(code, bodyStart); + var statement = code[bodyStart..statementEnd].Trim(); + return StartsWithCSharpKeyword(statement, "return") || StartsWithCSharpKeyword(statement, "throw"); + } + + var bodyEnd = FindMatchingDelimiter(code, bodyStart, '{', '}'); + return bodyEnd > bodyStart && ContainsTopLevelTerminatingStatement(code[(bodyStart + 1)..bodyEnd]); + } + + private static bool IsLineInsideGuardBranch( + SortedDictionary lines, + int ifLine, + int targetLine) + { + var segment = BuildStructuralSourceSegment(lines, ifLine, targetLine); + if (!segment.LineOffsets.TryGetValue(targetLine, out var targetLineOffset)) + return false; + var code = MaskCSharpNonCode(segment.Text); + var targetLineText = GetMaskedCSharpLine(lines, targetLine); + var readIndex = targetLineText.IndexOf("ReadAllText", StringComparison.Ordinal); + if (readIndex < 0) + return false; + var targetOffset = targetLineOffset + readIndex; + + var ifIndex = FindCSharpKeyword(code, "if"); + if (ifIndex < 0) + return false; + var openParen = code.IndexOf('(', ifIndex + 2); + if (openParen < 0) + return false; + var closeParen = FindMatchingDelimiter(code, openParen, '(', ')'); + if (closeParen < 0) + return false; + + var bodyStart = SkipCSharpWhitespace(code, closeParen + 1); + if (targetOffset < bodyStart || bodyStart >= code.Length) + return false; + if (code[bodyStart] != '{') + return targetOffset < FindTopLevelStatementEnd(code, bodyStart); + + var depth = 0; + for (var i = bodyStart; i < Math.Min(targetOffset, code.Length); i++) + { + if (code[i] == '{') + depth++; + else if (code[i] == '}' && --depth == 0) + return false; + } + return depth > 0; + } + + private static bool ResolvedCallDominatesRead( + SortedDictionary lines, + int callLine, + string callName, + int readLine) + { + if (IsImmediatelyControlledByUnbracedConstruct(lines, callLine, callName)) + return false; + + var callPath = GetStructuralBracePath(lines, callLine, callName); + var readPath = GetStructuralBracePath(lines, readLine, "ReadAllText"); + return callPath.Count <= readPath.Count && + callPath.SequenceEqual(readPath.Take(callPath.Count)) && + FindNearestSwitchSectionLine(lines, callLine) == FindNearestSwitchSectionLine(lines, readLine); + } + + private static int? FindNearestSwitchSectionLine(SortedDictionary lines, int targetLine) + { + int? sectionLine = null; + foreach (var (line, _) in lines) + { + if (line > targetLine) + break; + var code = GetMaskedCSharpLine(lines, line).TrimStart(); + if (StartsWithCSharpKeyword(code, "case") || code.StartsWith("default:", StringComparison.Ordinal)) + sectionLine = line; + } + return sectionLine; + } + + private static bool IsTopLevelContainerStatement( + SortedDictionary lines, + int containerStartLine, + int line, + string marker) + { + var scopedLines = new SortedDictionary( + lines.Where(pair => pair.Key >= containerStartLine) + .ToDictionary(pair => pair.Key, pair => pair.Value)); + return GetStructuralBracePath(scopedLines, line, marker).Count <= 1 && + !IsImmediatelyControlledByUnbracedConstruct(scopedLines, line, marker); + } + + private static bool IsAwaitedOrSynchronouslyCompleted(string invocationText) + { + var code = MaskCSharpNonCode(invocationText); + if (FindCSharpKeyword(code, "await") >= 0) + return true; + var normalized = NormalizeCSharpExpression(code); + return normalized.Contains(".GetAwaiter().GetResult(", StringComparison.Ordinal); + } + + private static bool IsImmediatelyControlledByUnbracedConstruct( + SortedDictionary lines, + int line, + string marker) + { + var current = GetMaskedCSharpLine(lines, line); + var markerIndex = current.IndexOf(marker, StringComparison.Ordinal); + if (markerIndex >= 0) + { + var prefix = current[..markerIndex]; + if (FindLastControlKeyword(prefix) >= 0 || prefix.Contains('?')) + return true; + } + + var preceding = BuildStructuralSourceSegment(lines, Math.Max(lines.Keys.FirstOrDefault(), line - 12), line - 1); + var code = MaskCSharpNonCode(preceding.Text).TrimEnd(); + var controlIndex = FindLastControlKeyword(code); + if (controlIndex < 0) + return false; + var openParen = code.IndexOf('(', controlIndex); + if (openParen < 0) + return false; + var closeParen = FindMatchingDelimiter(code, openParen, '(', ')'); + return closeParen >= 0 && string.IsNullOrWhiteSpace(code[(closeParen + 1)..]); + } + + private static List<(int Line, int Column)> GetStructuralBracePath( + SortedDictionary lines, + int targetLine, + string marker) + { + var source = BuildStructuralSourceSegment(lines, lines.Keys.FirstOrDefault(), targetLine); + var code = MaskCSharpNonCode(source.Text); + if (!source.LineOffsets.TryGetValue(targetLine, out var targetLineOffset)) + return []; + var targetLineText = GetMaskedCSharpLine(lines, targetLine); + var markerIndex = targetLineText.IndexOf(marker, StringComparison.Ordinal); + var targetOffset = targetLineOffset + Math.Max(0, markerIndex); + var path = new List<(int Line, int Column)>(); + var line = source.LineOffsets.Count == 0 ? 0 : source.LineOffsets.Keys.Min(); + var column = 0; + for (var i = 0; i < Math.Min(targetOffset, code.Length); i++) + { + if (code[i] == '\n') + { + line++; + column = 0; + continue; + } + column++; + if (code[i] == '{') + path.Add((line, column)); + else if (code[i] == '}' && path.Count > 0) + path.RemoveAt(path.Count - 1); + } + return path; + } + + private static int FindLastControlKeyword(string code) + { + var result = -1; + foreach (var keyword in new[] { "if", "for", "foreach", "while", "switch", "when" }) + { + var searchIndex = 0; + while (searchIndex < code.Length) + { + var index = FindCSharpKeyword(code, keyword, searchIndex); + if (index < 0) + break; + result = Math.Max(result, index); + searchIndex = index + keyword.Length; + } + } + return result; + } + + private static int FindCSharpKeyword(string code, string keyword, int startIndex = 0) + { + var index = Math.Max(0, startIndex); + while (index < code.Length) + { + index = code.IndexOf(keyword, index, StringComparison.Ordinal); + if (index < 0) + return -1; + var before = index == 0 || !IsCSharpIdentifierCharacter(code[index - 1]); + var afterIndex = index + keyword.Length; + var after = afterIndex >= code.Length || !IsCSharpIdentifierCharacter(code[afterIndex]); + if (before && after) + return index; + index = afterIndex; + } + return -1; + } + + private static bool StartsWithCSharpKeyword(string code, string keyword) + => code.StartsWith(keyword, StringComparison.Ordinal) && + (code.Length == keyword.Length || !IsCSharpIdentifierCharacter(code[keyword.Length])); + + private static bool IsCSharpIdentifierCharacter(char ch) + => char.IsLetterOrDigit(ch) || ch == '_'; + + private static int SkipCSharpWhitespace(string text, int index) + { + while (index < text.Length && char.IsWhiteSpace(text[index])) + index++; + return index; + } + + private static int FindTopLevelStatementEnd(string code, int startIndex) + { + var paren = 0; + var bracket = 0; + var brace = 0; + for (var i = startIndex; i < code.Length; i++) + { + switch (code[i]) + { + case '(': + paren++; + break; + case ')': + paren--; + break; + case '[': + bracket++; + break; + case ']': + bracket--; + break; + case '{': + brace++; + break; + case '}': + if (brace == 0) + return i; + brace--; + break; + case ';' when paren == 0 && bracket == 0 && brace == 0: + return i + 1; + } + } + return code.Length; + } + + private static bool ContainsTopLevelTerminatingStatement(string body) + { + var statementStart = 0; + var depth = 0; + for (var i = 0; i < body.Length; i++) + { + if (body[i] == '{') + { + if (depth++ == 0) + statementStart = i + 1; + continue; + } + if (body[i] == '}') + { + if (depth > 0 && --depth == 0) + statementStart = i + 1; + continue; + } + if (body[i] != ';' || depth != 0) + continue; + + var statement = body[statementStart..i].Trim(); + if (StartsWithCSharpKeyword(statement, "return") || StartsWithCSharpKeyword(statement, "throw")) + return true; + statementStart = i + 1; + } + return false; + } + + private static StructuralSourceSegment BuildStructuralSourceSegment( + SortedDictionary lines, + int startLine, + int endLine) + { + var builder = new StringBuilder(); + var offsets = new Dictionary(); + foreach (var (line, text) in lines) + { + if (line < startLine || line > endLine) + continue; + if (builder.Length > 0) + builder.Append('\n'); + offsets[line] = builder.Length; + builder.Append(text); + } + return new StructuralSourceSegment(builder.ToString(), offsets); + } + + private static string GetMaskedCSharpLine(SortedDictionary lines, int line) + { + var source = BuildStructuralSourceSegment(lines, lines.Keys.FirstOrDefault(), line); + if (!source.LineOffsets.TryGetValue(line, out var offset)) + return string.Empty; + var masked = MaskCSharpNonCode(source.Text); + var end = masked.IndexOf('\n', offset); + return end < 0 ? masked[offset..] : masked[offset..end]; + } + + private static string MaskCSharpNonCode(string text) + { + var chars = text.ToCharArray(); + var inLineComment = false; + var inBlockComment = false; + var inString = false; + var delimiter = '\0'; + var verbatim = false; + var rawQuoteCount = 0; + for (var i = 0; i < chars.Length; i++) + { + var ch = chars[i]; + var next = i + 1 < chars.Length ? chars[i + 1] : '\0'; + if (inLineComment) + { + if (ch == '\n') + inLineComment = false; + else + chars[i] = ' '; + continue; + } + if (inBlockComment) + { + if (ch == '*' && next == '/') + { + chars[i] = chars[i + 1] = ' '; + i++; + inBlockComment = false; + } + else if (ch != '\n') + { + chars[i] = ' '; + } + continue; + } + if (inString) + { + if (ch == '\n') + continue; + if (rawQuoteCount > 0 && ch == '"' && CountRun(text, i, '"') >= rawQuoteCount) + { + for (var quote = 0; quote < rawQuoteCount; quote++) + chars[i + quote] = ' '; + i += rawQuoteCount - 1; + inString = false; + rawQuoteCount = 0; + continue; + } + if (rawQuoteCount == 0 && ch == delimiter) + { + if (verbatim && next == delimiter) + { + chars[i] = chars[i + 1] = ' '; + i++; + continue; + } + if (!verbatim && i > 0 && IsEscapedCharacter(text, i)) + { + chars[i] = ' '; + continue; + } + chars[i] = ' '; + inString = false; + continue; + } + chars[i] = ' '; + continue; + } + + if (ch == '/' && next == '/') + { + chars[i] = chars[i + 1] = ' '; + i++; + inLineComment = true; + continue; + } + if (ch == '/' && next == '*') + { + chars[i] = chars[i + 1] = ' '; + i++; + inBlockComment = true; + continue; + } + if (ch is '"' or '\'') + { + var quoteCount = ch == '"' ? CountRun(text, i, '"') : 1; + rawQuoteCount = quoteCount >= 3 ? quoteCount : 0; + delimiter = ch; + verbatim = rawQuoteCount == 0 && ch == '"' && i > 0 && text[i - 1] == '@'; + inString = true; + var maskCount = Math.Max(1, rawQuoteCount); + for (var quote = 0; quote < maskCount && i + quote < chars.Length; quote++) + chars[i + quote] = ' '; + i += maskCount - 1; + } + } + return new string(chars); + } + + private static int CountRun(string text, int start, char value) + { + var count = 0; + while (start + count < text.Length && text[start + count] == value) + count++; + return count; + } + + private static bool IsEscapedCharacter(string text, int index) + { + var slashCount = 0; + for (var i = index - 1; i >= 0 && text[i] == '\\'; i--) + slashCount++; + return slashCount % 2 != 0; + } + + private static bool HasAssignmentBetween( + SortedDictionary lines, + string alias, + int startLine, + int endLine) + { + foreach (var (lineNumber, text) in lines) + { + if (lineNumber < startLine || lineNumber > endLine) + continue; + var normalized = NormalizeCSharpExpression(text); + var marker = alias + "="; + var index = normalized.IndexOf(marker, StringComparison.Ordinal); + if (index >= 0 && !IsComparisonOperator(normalized, index + alias.Length)) + return true; + } + return false; + } + + private static bool IsComparisonOperator(string text, int equalsIndex) + => (equalsIndex > 0 && text[equalsIndex - 1] is '!' or '<' or '>' or '=') || + (equalsIndex + 1 < text.Length && text[equalsIndex + 1] is '=' or '>'); + + private static bool ContainsBoundToken(string normalized) + => normalized.Contains("max", StringComparison.OrdinalIgnoreCase) || + normalized.Contains("limit", StringComparison.OrdinalIgnoreCase) || + normalized.Contains("cap", StringComparison.OrdinalIgnoreCase) || + normalized.Any(char.IsDigit); + + private static bool IsTaskLike(string? returnType) + => returnType != null && + (returnType.Contains("Task", StringComparison.Ordinal) || returnType.Contains("ValueTask", StringComparison.Ordinal)); + + private static List ParseParameterNames(string signature) + { + var openParen = signature.IndexOf('('); + if (openParen < 0) + return []; + var closeParen = FindMatchingDelimiter(signature, openParen, '(', ')'); + if (closeParen < 0) + return []; + return SplitTopLevelArguments(signature[(openParen + 1)..closeParen]) + .Select(parameter => parameter.Split('=')[0]) + .Select(LastIdentifier) + .Where(name => name != null) + .Select(name => name!) + .ToList(); + } + + private void AddRejectedStructuralEvidence( + List rejected, + string path, + int line, + string text, + SearchGuardFilter filter, + string reason, + string relationship, + string? subject, + string? container, + string? lang) + { + if (rejected.Count >= MaxStructuralGuardEvidenceCandidates) + return; + rejected.Add(CreateStructuralEvidence( + path, + line, + text, + filter, + "rejected", + reason, + relationship, + subject, + container, + lang)); + } + + private static SearchGuardEvidence CreateStructuralEvidence( + string path, + int line, + string text, + SearchGuardFilter filter, + string decision, + string reason, + string relationship, + string? subject, + string? container, + string? lang) + { + var matchText = subject ?? filter.Query; + var columnIndex = text.IndexOf(matchText, StringComparison.Ordinal); + if (columnIndex < 0) + columnIndex = 0; + var length = Math.Max(1, Math.Min(matchText.Length, Math.Max(1, text.Length - columnIndex))); + var facet = SearchMatchClassifier.Classify(path, lang, line, text, columnIndex + 1, length); + return new SearchGuardEvidence + { + Role = FormatSearchGuardRole(filter.Role), + Direction = FormatSearchGuardDirection(filter.Direction), + Scope = "container", + Query = filter.Query, + Name = FormatSearchGuardName(filter), + Pattern = filter.Query, + Relationship = relationship, + Decision = decision, + Reason = reason, + EvidencePath = path, + Subject = subject, + Container = container, + Span = new SearchGuardSpan + { + Line = line, + Column = columnIndex + 1, + Length = length, + }, + Line = line, + Column = columnIndex + 1, + Length = length, + Origin = facet.Origin, + Text = text, + }; + } + + private sealed record SearchGuardContainer( + long SymbolId, + string Name, + string? ContainerName, + int StartLine, + int EndLine); + + private sealed record FileSizeSource(int Line, string Expression, string? Alias); + + private sealed record ResolvedStructuralCall( + int Line, + string Name, + string Signature, + string? ReturnType, + int StartLine, + int EndLine, + int BodyStartLine, + string Path); + + private sealed record EnumerationOptionsDefinition(int Line, string Text, string Container); + + private sealed record LexicalEnumerationOptionsBinding(bool IsEnumerationOptions, int Line, string Text); + + private sealed record InvocationText(string Text, List Arguments); + + private sealed record StructuralSourceSegment(string Text, Dictionary LineOffsets); +} diff --git a/src/CodeIndex/Database/DbSearchReader.cs b/src/CodeIndex/Database/DbSearchReader.cs index f99e5eaaf..6441da232 100644 --- a/src/CodeIndex/Database/DbSearchReader.cs +++ b/src/CodeIndex/Database/DbSearchReader.cs @@ -1348,7 +1348,11 @@ private static string[] BuildPrimarySearchMatchTerms(string query, string normal .ToArray(); } - private sealed record SearchGuardEvaluation(int WindowStartLine, int WindowEndLine, SearchGuardEvidence? Evidence); + private sealed record SearchGuardEvaluation( + int WindowStartLine, + int WindowEndLine, + SearchGuardEvidence? Evidence, + List? RejectedEvidence = null); private SearchGuardEvaluation FindGuardEvidence( string path, @@ -1359,6 +1363,9 @@ private SearchGuardEvaluation FindGuardEvidence( string? lang, Dictionary> lineWindowCache) { + if (filter.EvidenceKind != SearchGuardEvidenceKind.Text) + return FindStructuralGuardEvidence(path, primaryMatch, filter, lang, lineWindowCache); + if (guardScope == SearchGuardScope.SameLine) return FindSameLineGuardEvidence(path, primaryMatch, filter, lang); @@ -1523,6 +1530,7 @@ private static SearchGuardCheck CreateSearchGuardCheck(SearchGuardFilter filter, WindowStartLine = evaluation.WindowStartLine, WindowEndLine = evaluation.WindowEndLine, Evidence = evaluation.Evidence, + RejectedEvidence = evaluation.RejectedEvidence, }; } @@ -1549,7 +1557,12 @@ private static string FormatSearchGuardSummary(string name, string pattern, bool } private static string FormatSearchGuardScope(SearchGuardScope scope) - => scope == SearchGuardScope.SameLine ? "same_line" : "window"; + => scope switch + { + SearchGuardScope.SameLine => "same_line", + SearchGuardScope.Container => "container", + _ => "window", + }; private SortedDictionary ReadLineWindow( string path, diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index 08a3c8a30..311816881 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -130,6 +130,14 @@ public enum SearchGuardScope { Window, SameLine, + Container, +} + +public enum SearchGuardEvidenceKind +{ + Text, + CSharpBoundedFileRead, + CSharpEnumerationOptions, } public enum SearchResultRanking @@ -142,7 +150,8 @@ public sealed record SearchGuardFilter( SearchGuardRole Role, SearchGuardDirection Direction, string Query, - SearchGuardScope? Scope = null); + SearchGuardScope? Scope = null, + SearchGuardEvidenceKind EvidenceKind = SearchGuardEvidenceKind.Text); public sealed class SearchGuardEvidence { @@ -153,6 +162,15 @@ public sealed class SearchGuardEvidence public string Name { get; set; } = string.Empty; public string Pattern { get; set; } = string.Empty; public string Relationship { get; set; } = string.Empty; + public string Decision { get; set; } = "accepted"; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Reason { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? EvidencePath { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Subject { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Container { get; set; } public SearchGuardSpan Span { get; set; } = new(); public int Line { get; set; } public int Column { get; set; } @@ -177,6 +195,8 @@ public sealed class SearchGuardCheck public int WindowEndLine { get; set; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public SearchGuardEvidence? Evidence { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? RejectedEvidence { get; set; } } public sealed class SearchGuardSpan diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchGuardTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchGuardTests.cs index 78a968b5c..bdc29aaca 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchGuardTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchGuardTests.cs @@ -212,6 +212,83 @@ public void NonAtomic(string path) } } + [Fact] + public void Search_StructuralRequireReturnsAcceptedEvidence_Issue4912() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_structural_require_evidence"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/app.cs", + "csharp", + """ + public static class App + { + private const long MaxBytes = 1024; + + public static string LocalBound(string path) + { + var length = new FileInfo(path).Length; + if (length > MaxBytes) + throw new InvalidDataException(); + return File.ReadAllText(path); + } + + public static string HelperBound(string path) + { + WriteBounded(path); + return File.ReadAllText(path); + } + + private static void WriteBounded(string path) + { + BoundedFile.WriteAllText(path, MaxBytes); + } + + public static string Unguarded(string path) + => File.ReadAllText(path); + } + """); + + using var db = new DbContext(DbOpenIntent.QueryOnly, dbPath); + var reader = new DbReader(db); + var results = reader.Search( + "File.ReadAllText", + limit: 10, + lang: "csharp", + exact: true, + guardFilters: + [ + new( + SearchGuardRole.Require, + SearchGuardDirection.Before, + "bounded-file-read", + SearchGuardScope.Container, + SearchGuardEvidenceKind.CSharpBoundedFileRead) + ], + guardScope: SearchGuardScope.Container); + + Assert.True( + results.Count == 2, + $"Expected both accepted structural relationships, got: {string.Join(", ", results.SelectMany(result => result.GuardEvidence ?? []).Select(item => item.Relationship))}"); + var evidence = results.Select(result => Assert.Single(result.GuardEvidence!)).ToArray(); + Assert.All(evidence, item => + { + Assert.Equal("accepted", item.Decision); + Assert.Equal("container", item.Scope); + Assert.Equal("src/app.cs", item.EvidencePath); + }); + Assert.Contains(evidence, item => item.Relationship == "same_path_size_guard"); + Assert.Contains(evidence, item => item.Relationship == "same_path_resolved_bounded_writer"); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunSearch_GuardScopeSameLineUsesPrimaryMatchColumns_Issue3730() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index e2e67c77f..7f14aac53 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -2691,13 +2691,12 @@ public void RunSearch_ListRecipesJsonIncludesBuiltInAuditMetadata_Issue3144() Assert.Contains(xmlResolverQuery.GetProperty("risk_evidence").EnumerateArray(), evidence => evidence.GetString()!.Contains("external entity", StringComparison.Ordinal)); Assert.Contains(traversalRecipe.GetProperty("queries").EnumerateArray(), item => item.GetProperty("name").GetString() == "enumerate-files"); Assert.Equal("Directory.Enumerate", enumerateWithoutOptionsQuery.GetProperty("query").GetString()); - Assert.Contains(enumerateWithoutOptionsQuery.GetProperty("risk_evidence").EnumerateArray(), evidence => evidence.GetString()!.Contains("without nearby EnumerationOptions", StringComparison.Ordinal)); + Assert.Contains(enumerateWithoutOptionsQuery.GetProperty("risk_evidence").EnumerateArray(), evidence => evidence.GetString()!.Contains("without a resolved EnumerationOptions", StringComparison.Ordinal)); Assert.Contains(enumerateWithoutOptionsQuery.GetProperty("guard_filters").EnumerateArray(), filter => filter.GetProperty("option").GetString() == "--reject-before" && - filter.GetProperty("query").GetString() == "EnumerationOptions"); - Assert.Contains(enumerateWithoutOptionsQuery.GetProperty("guard_filters").EnumerateArray(), filter => - filter.GetProperty("option").GetString() == "--reject-after" && - filter.GetProperty("query").GetString() == "EnumerationOptions"); + filter.GetProperty("query").GetString() == "configured-enumeration-options" && + filter.GetProperty("scope").GetString() == "container" && + filter.GetProperty("evidence_kind").GetString() == "csharp_enumeration_options"); Assert.Equal("BoundedFile.OpenReadFor", boundedFileOpenHelperQuery.GetProperty("query").GetString()); Assert.Contains(boundedFileOpenHelperQuery.GetProperty("risk_evidence").EnumerateArray(), evidence => evidence.GetString()!.Contains("FileShare.ReadWrite", StringComparison.Ordinal)); Assert.Contains(boundedMemoryAccumulatorQuery.GetProperty("risk_evidence").EnumerateArray(), evidence => evidence.GetString()!.Contains("max-byte", StringComparison.Ordinal)); @@ -6787,7 +6786,7 @@ static void AssertRecipeQueryPath(List queries, string queryName, s } [Fact] - public void RunSearch_FilesystemTraversalRecipeFiltersNearbyEnumerationOptions_Issue3920() + public void RunSearch_FilesystemTraversalRecipeResolvesEnumerationOptionsStructurally_Issue4912() { var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_recipe_enumeration_options"); try @@ -6826,6 +6825,114 @@ public void Run(string root) } } """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/distant-options.cs", + "csharp", + """ + public static class TraversalPolicy + { + public static void Run(string root) + { + Console.WriteLine("policy"); + Console.WriteLine("kept structurally distant"); + Console.WriteLine("from the options declaration"); + Console.WriteLine("without relying on line windows"); + foreach (var path in Directory.EnumerateFiles(root, "*", TopOnly)) + { + Console.WriteLine(path); + } + } + + private static readonly EnumerationOptions TopOnly = CreateOptions(); + + private static EnumerationOptions CreateOptions() => new() + { + RecurseSubdirectories = false, + IgnoreInaccessible = false, + }; + } + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/unrelated-options.cs", + "csharp", + """ + public sealed class UnrelatedOptionsTraversal + { + public void Run(string root) + { + var unrelated = new EnumerationOptions { RecurseSubdirectories = false }; + Console.WriteLine(unrelated.RecurseSubdirectories); + foreach (var path in Directory.EnumerateFiles(root)) + { + Console.WriteLine(path); + } + } + } + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/call-options.cs", + "csharp", + """ + public sealed class CallOptionsTraversal + { + private static readonly EnumerationOptions SharedOptions = new(); + + public void Inline(string root) + { + foreach (var path in Directory.EnumerateFiles(root, "*", new EnumerationOptions())) + Console.WriteLine(path); + } + + public void Parameter(string root, EnumerationOptions options) + { + foreach (var path in Directory.EnumerateFiles(root, "*", options)) + Console.WriteLine(path); + } + + public void SameReceiver(string root) + { + foreach (var path in Directory.EnumerateFiles(root, "*", CallOptionsTraversal.SharedOptions)) + Console.WriteLine(path); + } + } + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/named-options.cs", + "csharp", + """ + public sealed class NamedOptionsTraversal + { + public void Run(string root) + { + var localOptions = new EnumerationOptions(); + foreach (var path in Directory.EnumerateFiles( + enumerationOptions: localOptions, + searchPattern: "*", + path: root)) + Console.WriteLine(path); + } + } + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/shadowed-options.cs", + "csharp", + """ + public sealed class ShadowedOptionsTraversal + { + private static readonly EnumerationOptions options = new(); + + public void Run(string root, SearchOption options) + { + foreach (var path in Directory.EnumerateFiles(root, "*", options)) + Console.WriteLine(path); + } + } + """); var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( ["--recipe", "filesystem-traversal/enumerate-without-options", "--db", dbPath, "--json", "--limit", "10"], @@ -6836,14 +6943,318 @@ public void Run(string root) using var document = ParseJsonOutput(stdout); var root = document.RootElement; var query = Assert.Single(root.GetProperty("queries").EnumerateArray()); - var result = Assert.Single(query.GetProperty("results").EnumerateArray()); + var results = query.GetProperty("results").EnumerateArray().ToArray(); Assert.Equal("enumerate-without-options", query.GetProperty("name").GetString()); - Assert.Equal(1, query.GetProperty("count").GetInt32()); - Assert.Equal("src/unguarded.cs", result.GetProperty("path").GetString()); + Assert.True( + query.GetProperty("count").GetInt32() == 3, + $"Unexpected traversal results: {string.Join(", ", results.Select(result => result.GetProperty("path").GetString()))}"); + Assert.Contains(results, result => result.GetProperty("path").GetString() == "src/unguarded.cs"); + Assert.Contains(results, result => result.GetProperty("path").GetString() == "src/unrelated-options.cs"); + Assert.Contains(results, result => result.GetProperty("path").GetString() == "src/shadowed-options.cs"); Assert.DoesNotContain(query.GetProperty("top_files").EnumerateArray(), item => item.GetProperty("path").GetString() == "src/options.cs"); - Assert.Contains(query.GetProperty("guard_filters").EnumerateArray(), filter => filter.GetProperty("option").GetString() == "--reject-before"); - Assert.Contains(query.GetProperty("guard_filters").EnumerateArray(), filter => filter.GetProperty("option").GetString() == "--reject-after"); + Assert.DoesNotContain(query.GetProperty("top_files").EnumerateArray(), item => item.GetProperty("path").GetString() == "src/distant-options.cs"); + Assert.DoesNotContain(query.GetProperty("top_files").EnumerateArray(), item => item.GetProperty("path").GetString() == "src/call-options.cs"); + Assert.DoesNotContain(query.GetProperty("top_files").EnumerateArray(), item => item.GetProperty("path").GetString() == "src/named-options.cs"); + var guardFilter = Assert.Single(query.GetProperty("guard_filters").EnumerateArray()); + Assert.Equal("--reject-before", guardFilter.GetProperty("option").GetString()); + Assert.Equal("container", guardFilter.GetProperty("scope").GetString()); + Assert.Equal("csharp_enumeration_options", guardFilter.GetProperty("evidence_kind").GetString()); + + var unguarded = Assert.Single(results, result => result.GetProperty("path").GetString() == "src/unguarded.cs"); + var check = Assert.Single(unguarded.GetProperty("guard_checks").EnumerateArray()); + Assert.False(check.GetProperty("matched").GetBoolean()); + Assert.True(check.GetProperty("passed").GetBoolean()); + Assert.Equal("container", check.GetProperty("scope").GetString()); + var rejectedEvidence = Assert.Single(check.GetProperty("rejected_evidence").EnumerateArray()); + Assert.Equal("rejected", rejectedEvidence.GetProperty("decision").GetString()); + Assert.Equal("enumeration_options_argument_missing", rejectedEvidence.GetProperty("relationship").GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunSearch_ReadAllTextRecipeUsesSamePathAndResolvedHelperEvidence_Issue4912() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_recipe_structural_read_guard"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/reads.cs", + "csharp", + """ + public static class Reads + { + private const long MaxBytes = 1024; + + public static string LocalBound(string path) + { + var length = new FileInfo(path).Length; + Console.WriteLine("distance-1"); + Console.WriteLine("distance-2"); + Console.WriteLine("distance-3"); + Console.WriteLine("distance-4"); + Console.WriteLine("distance-5"); + Console.WriteLine("distance-6"); + Console.WriteLine("distance-7"); + Console.WriteLine("distance-8"); + Console.WriteLine("distance-9"); + if (length > MaxBytes) + throw new InvalidDataException(); + return File.ReadAllText(path); + } + + public static string HelperBound(string path) + { + WriteBounded(path); + return File.ReadAllText(path); + } + + private static void WriteBounded(string path) + { + BoundedFile.WriteAllText(path, MaxBytes); + } + + public static string FileInfoBound(string path) + { + var info = new FileInfo(path); + if (info.Length > MaxBytes) + throw new InvalidDataException(); + return File.ReadAllText(path); + } + + public static string Unguarded(string path) + => File.ReadAllText(path); + + public static string Unrelated(string path, string other) + { + var length = new FileInfo(other).Length; + if (length > MaxBytes) + throw new InvalidDataException(); + return File.ReadAllText(path); + } + + public static string Inverted(string path) + { + var length = new FileInfo(path).Length; + if (length < MaxBytes) + throw new InvalidDataException(); + return File.ReadAllText(path); + } + + public static string ConditionalReject(string path, bool extraCondition) + { + var length = new FileInfo(path).Length; + if (length > MaxBytes && extraCondition) + throw new InvalidDataException(); + return File.ReadAllText(path); + } + + public static string Unawaited(string path) + { + WriteBoundedAsync(path); + return File.ReadAllText(path); + } + + public static string AwaitNamedVariable(string path) + { + var awaitedWrite = WriteBoundedAsync(path); + return File.ReadAllText(path); + } + + public static string ConditionalHelper(string path, bool shouldWrite) + { + if (shouldWrite) + WriteBounded(path); + return File.ReadAllText(path); + } + + public static string CommentOnlyHelper(string path) + { + Prepare(path); + return File.ReadAllText(path); + } + + private static void Prepare(string path) + { + // BoundedFile.WriteAllText(path, MaxBytes); + Console.WriteLine(path); + } + + public static string PathReassigned(string path, string other) + { + var length = new FileInfo(path).Length; + if (length > MaxBytes) + throw new InvalidDataException(); + path = other; + return File.ReadAllText(path); + } + + public static string LaterConditionalThrow(string path, bool fatal) + { + var length = new FileInfo(path).Length; + if (length > MaxBytes) + Console.WriteLine("too large but continuing"); + if (fatal) + throw new InvalidOperationException(); + return File.ReadAllText(path); + } + + public static string ElseReadsOversized(string path) + { + var length = new FileInfo(path).Length; + if (length < MaxBytes) + { + return string.Empty; + } + else + { + return File.ReadAllText(path); + } + } + + public static string NamedArgumentBound(string path) + { + var length = new FileInfo(path).Length; + if (length > MaxBytes) + throw new InvalidDataException(); + return File.ReadAllText(path: path); + } + + public static string InlineThrowBound(string path) + { + var length = new FileInfo(path).Length; + if (length > MaxBytes) throw new InvalidDataException(); + return File.ReadAllText(path); + } + + public static async Task AwaitedHelperBound(string path) + { + await WriteBoundedAsync(path); + return File.ReadAllText(path); + } + + public static string NameOnlyHelper(string path) + { + MisleadingBounded(path); + return File.ReadAllText(path); + } + + private static void MisleadingBounded(string path) + => Console.WriteLine(path, MaxBytes); + + public static string NamedHelperMismatch(string path, string cache) + { + PrepareNamed(target: path, other: cache); + return File.ReadAllText(path); + } + + private static void PrepareNamed(string other, string target) + { + BoundedFile.WriteAllText(other, MaxBytes); + } + + public static string SwitchSections(string path, int kind) + { + switch (kind) + { + case 0: + WriteBounded(path); + break; + case 1: + return File.ReadAllText(path); + default: + return string.Empty; + } + } + + public static string ConditionalSizeSource(string path, bool inspectFile) + { + var length = inspectFile ? new FileInfo(path).Length : 0; + if (length > MaxBytes) + throw new InvalidDataException(); + return File.ReadAllText(path); + } + + public static string CommentSizeSource(string path, Stream other) + { + var length = other.Length; + // length = new FileInfo(path).Length; + if (length > MaxBytes) + throw new InvalidDataException(); + return File.ReadAllText(path); + } + + public static string ReorderedNamedRead(string path) + { + var length = new FileInfo(path).Length; + if (length > MaxBytes) + throw new InvalidDataException(); + return File.ReadAllText(encoding: Encoding.UTF8, path: path); + } + + private static Task WriteBoundedAsync(string path) + { + BoundedFile.WriteAllText(path, MaxBytes); + return Task.CompletedTask; + } + } + """); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "risky-code/file-read-all-text", "--db", dbPath, "--json", "--limit", "20"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var query = Assert.Single(document.RootElement.GetProperty("queries").EnumerateArray()); + var results = query.GetProperty("results").EnumerateArray().ToArray(); + + Assert.True( + query.GetProperty("count").GetInt32() == 16, + $"Unexpected ReadAllText results: {string.Join(", ", results.Select(result => result.GetProperty("enclosing_symbol_name").GetString()))}"); + Assert.DoesNotContain(results, result => result.GetProperty("enclosing_symbol_name").GetString() == "LocalBound"); + Assert.DoesNotContain(results, result => result.GetProperty("enclosing_symbol_name").GetString() == "HelperBound"); + Assert.DoesNotContain(results, result => result.GetProperty("enclosing_symbol_name").GetString() == "FileInfoBound"); + Assert.DoesNotContain(results, result => result.GetProperty("enclosing_symbol_name").GetString() == "NamedArgumentBound"); + Assert.DoesNotContain(results, result => result.GetProperty("enclosing_symbol_name").GetString() == "InlineThrowBound"); + Assert.DoesNotContain(results, result => result.GetProperty("enclosing_symbol_name").GetString() == "AwaitedHelperBound"); + Assert.DoesNotContain(results, result => result.GetProperty("enclosing_symbol_name").GetString() == "ReorderedNamedRead"); + Assert.Contains(results, result => result.GetProperty("enclosing_symbol_name").GetString() == "Unguarded"); + Assert.Contains(results, result => result.GetProperty("enclosing_symbol_name").GetString() == "Unrelated"); + Assert.Contains(results, result => result.GetProperty("enclosing_symbol_name").GetString() == "Inverted"); + Assert.Contains(results, result => result.GetProperty("enclosing_symbol_name").GetString() == "ConditionalReject"); + Assert.Contains(results, result => result.GetProperty("enclosing_symbol_name").GetString() == "Unawaited"); + Assert.Contains(results, result => result.GetProperty("enclosing_symbol_name").GetString() == "AwaitNamedVariable"); + Assert.Contains(results, result => result.GetProperty("enclosing_symbol_name").GetString() == "ConditionalHelper"); + Assert.Contains(results, result => result.GetProperty("enclosing_symbol_name").GetString() == "CommentOnlyHelper"); + Assert.Contains(results, result => result.GetProperty("enclosing_symbol_name").GetString() == "PathReassigned"); + Assert.Contains(results, result => result.GetProperty("enclosing_symbol_name").GetString() == "LaterConditionalThrow"); + Assert.Contains(results, result => result.GetProperty("enclosing_symbol_name").GetString() == "ElseReadsOversized"); + Assert.Contains(results, result => result.GetProperty("enclosing_symbol_name").GetString() == "NameOnlyHelper"); + Assert.Contains(results, result => result.GetProperty("enclosing_symbol_name").GetString() == "NamedHelperMismatch"); + Assert.Contains(results, result => result.GetProperty("enclosing_symbol_name").GetString() == "SwitchSections"); + Assert.Contains(results, result => result.GetProperty("enclosing_symbol_name").GetString() == "ConditionalSizeSource"); + Assert.Contains(results, result => result.GetProperty("enclosing_symbol_name").GetString() == "CommentSizeSource"); + + var guardFilter = Assert.Single(query.GetProperty("guard_filters").EnumerateArray()); + Assert.Equal("container", guardFilter.GetProperty("scope").GetString()); + Assert.Equal("csharp_bounded_file_read", guardFilter.GetProperty("evidence_kind").GetString()); + + var unrelated = Assert.Single(results, result => result.GetProperty("enclosing_symbol_name").GetString() == "Unrelated"); + var check = Assert.Single(unrelated.GetProperty("guard_checks").EnumerateArray()); + Assert.False(check.GetProperty("matched").GetBoolean()); + Assert.True(check.GetProperty("passed").GetBoolean()); + Assert.Contains( + check.GetProperty("rejected_evidence").EnumerateArray(), + evidence => evidence.GetProperty("relationship").GetString() == "different_path_size_source" + && evidence.GetProperty("decision").GetString() == "rejected"); } finally {