From c315f1f1930d4a7636138a2b00b9d364789e0669 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 2 Jun 2026 09:55:44 +0900 Subject: [PATCH 1/3] Require trust for workspace plugin DLLs (#2863) --- DEVELOPER_GUIDE.md | 2 +- changelog.d/unreleased/2863.security.md | 17 +++++++++ .../Extensibility/ExtractorPluginRegistry.cs | 17 ++++++++- tests/CodeIndex.Tests/SymbolExtractorTests.cs | 37 +++++++++++++++++++ 4 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/2863.security.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 7ef4a341c2..eb7c8c3b11 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1201,7 +1201,7 @@ Process exit codes are coarse (`0` success including valid zero-row queries, `1` - **Cross-compiled linux-arm64 without runtime smoke test** — The `release.yml` workflow cross-compiles `linux-arm64` on an x64 runner (`dotnet publish -r linux-arm64 --self-contained`). Tests are skipped because the runner cannot execute ARM binaries natively. Ideally, a QEMU-based smoke test (`cdidx --version`) would run before publishing, but GitHub Actions free-tier runners do not include QEMU or ARM runners. Adding a QEMU setup step is possible but increases CI complexity and wall-clock time for every release. .NET's cross-compilation is an officially supported and widely used feature, so the risk of a broken artifact is low in practice. If ARM-specific failures are reported in the future, adding `docker run --platform linux/arm64` with QEMU should be the first mitigation step. - **CLI / MCP only — no public library API (#1557)** — The `cdidx` assembly is shipped as `OutputType=Exe` with `PackAsTool=true` and is published as a .NET global tool, not as a referenceable library. The supported, versioned surfaces are the `cdidx` CLI (including its `--json` output) and the `cdidx mcp` JSON-RPC server. `public` types on the assembly (for example `CodeIndex.Database.DbReader` and DTOs in `CodeIndex.Models` / `CodeIndex.Database`) exist to satisfy CLI / MCP composition and the `CodeIndex.Tests` `InternalsVisibleTo` boundary — they are implementation details that may change, move, or become `internal` without a deprecation cycle. Embedders are expected to depend on the CLI / MCP / JSON surfaces, not on the assembly. See [INTEGRATION_POLICY.md — API Surface and Library Use](INTEGRATION_POLICY.md#api-surface-and-library-use). If a real library API is ever justified, it will be carved out as a separate package with its own interface and versioning contract rather than being implied by whatever happens to be `public` on this assembly. -- **Extractor plugins (#1937)** — `CodeIndex.Indexer.Extensibility.ISymbolExtractor` and `IReferenceExtractor` are the only supported assembly-extension surface. `cdidx` discovers trusted plugin DLLs in workspace `.cdidx/plugins/` and user `~/.cdidx/plugins/`. A plugin assembly must declare `[assembly: CdidxPlugin(minApiVersion: 1, maxApiVersion: 1)]` and expose a public parameterless type implementing one or both interfaces. Set `FileExtensions` when the plugin owns new file extensions so `FileIndexer` can route those files to the plugin language. Plugins run inside the `cdidx` process and are not sandboxed; install only trusted local DLLs. This narrow contract lets teams add DSL-specific symbols/references without forking CodeIndex, but it is not a general library/SDK embedding API. +- **Extractor plugins (#1937)** — `CodeIndex.Indexer.Extensibility.ISymbolExtractor` and `IReferenceExtractor` are the only supported assembly-extension surface. `cdidx` discovers trusted plugin DLLs in the user-owned `~/.cdidx/plugins/` directory by default. Workspace `.cdidx/plugins/` DLL discovery is fail-closed unless the process sets `CDIDX_TRUST_WORKSPACE_PLUGINS=1` (also accepts `true`, `yes`, or `on`), because loading a workspace DLL executes checkout-provided code inside the `cdidx` process. A plugin assembly must declare `[assembly: CdidxPlugin(minApiVersion: 1, maxApiVersion: 1)]` and expose a public parameterless type implementing one or both interfaces. Set `FileExtensions` when the plugin owns new file extensions so `FileIndexer` can route those files to the plugin language. Plugins run inside the `cdidx` process and are not sandboxed; install only trusted local DLLs. This narrow contract lets teams add DSL-specific symbols/references without forking CodeIndex, but it is not a general library/SDK embedding API. diff --git a/changelog.d/unreleased/2863.security.md b/changelog.d/unreleased/2863.security.md new file mode 100644 index 0000000000..a8b0aa99d5 --- /dev/null +++ b/changelog.d/unreleased/2863.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 2863 +affected: + - src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs + - tests/CodeIndex.Tests/SymbolExtractorTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Workspace extractor plugin DLLs now require explicit trust (#2863)** — `cdidx` no longer discovers workspace `.cdidx/plugins/*.dll` files by default; set `CDIDX_TRUST_WORKSPACE_PLUGINS=1` only for checkouts whose plugin code you trust. + +## 日本語 + +- **workspace extractor plugin DLL は明示的な trust が必要になりました (#2863)** — `cdidx` は既定で workspace の `.cdidx/plugins/*.dll` を discovery しません。信頼できる checkout の plugin code に限り `CDIDX_TRUST_WORKSPACE_PLUGINS=1` を設定してください。 diff --git a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs index 023c53aadd..205c7bc61b 100644 --- a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs +++ b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs @@ -7,6 +7,7 @@ namespace CodeIndex.Indexer.Extensibility; public static class ExtractorPluginRegistry { public const int CurrentApiVersion = 1; + internal const string TrustWorkspacePluginsEnvironmentVariable = "CDIDX_TRUST_WORKSPACE_PLUGINS"; private static readonly object Gate = new(); private static readonly Dictionary SymbolExtractors = new(StringComparer.Ordinal); @@ -101,6 +102,9 @@ internal static void ReloadForTests() } } + internal static IReadOnlyList EnumeratePluginAssemblyPathsForTests() + => EnumeratePluginAssemblyPaths().ToArray(); + internal static void LoadPatternConfigsForProjectRoot(string? projectRoot) { EnsurePluginsLoaded(); @@ -162,7 +166,8 @@ private static IEnumerable EnumeratePluginAssemblyPaths() private static IEnumerable EnumeratePluginDirectories() { - yield return Path.Combine(Environment.CurrentDirectory, ".cdidx", "plugins"); + if (WorkspacePluginsTrusted()) + yield return Path.Combine(Environment.CurrentDirectory, ".cdidx", "plugins"); var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); if (!string.IsNullOrWhiteSpace(home)) @@ -303,6 +308,16 @@ private static void TryRegisterPluginType(Type type) } } + private static bool WorkspacePluginsTrusted() + { + var value = Environment.GetEnvironmentVariable(TrustWorkspacePluginsEnvironmentVariable); + return value != null + && (value.Equals("1", StringComparison.OrdinalIgnoreCase) + || value.Equals("true", StringComparison.OrdinalIgnoreCase) + || value.Equals("yes", StringComparison.OrdinalIgnoreCase) + || value.Equals("on", StringComparison.OrdinalIgnoreCase)); + } + private static void AddLanguageExtensions( Dictionary target, IEnumerable<(string Language, IReadOnlyCollection FileExtensions)> plugins) diff --git a/tests/CodeIndex.Tests/SymbolExtractorTests.cs b/tests/CodeIndex.Tests/SymbolExtractorTests.cs index b95b8ad2e8..faf2da6261 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorTests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorTests.cs @@ -79,6 +79,43 @@ public void Extract_ConfiguredPatternYaml_HandlesOutOfTreeLanguage() } } + [Fact] + public void EnumeratePluginAssemblyPaths_SkipsWorkspacePluginsUnlessTrusted() + { + lock (TestConsoleLock.Gate) + { + using var env = EnvironmentVariableScope.Capture(ExtractorPluginRegistry.TrustWorkspacePluginsEnvironmentVariable); + var tempDir = Path.Combine(Path.GetTempPath(), $"cdidx_workspace_plugins_{Guid.NewGuid():N}"); + var originalDirectory = Environment.CurrentDirectory; + try + { + var pluginDir = Path.Combine(tempDir, ".cdidx", "plugins"); + Directory.CreateDirectory(pluginDir); + var pluginFileName = $"demo_{Guid.NewGuid():N}.dll"; + var pluginPath = Path.Combine(pluginDir, pluginFileName); + File.WriteAllText(pluginPath, "not a real dll"); + Environment.CurrentDirectory = tempDir; + env.Set(ExtractorPluginRegistry.TrustWorkspacePluginsEnvironmentVariable, null); + + var untrustedPaths = ExtractorPluginRegistry.EnumeratePluginAssemblyPathsForTests(); + + Assert.DoesNotContain(untrustedPaths, path => Path.GetFileName(path) == pluginFileName); + + env.Set(ExtractorPluginRegistry.TrustWorkspacePluginsEnvironmentVariable, "1"); + + var trustedPaths = ExtractorPluginRegistry.EnumeratePluginAssemblyPathsForTests(); + + Assert.Contains(trustedPaths, path => Path.GetFileName(path) == pluginFileName); + } + finally + { + Environment.CurrentDirectory = originalDirectory; + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, recursive: true); + } + } + } + [Fact] public void Extract_CsharpFileScopedNamespace_DoesNotEnterMemberHeaderMerge() { From 2e905ecb574996ea45014958b60d00ec00914a3e Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 2 Jun 2026 11:56:57 +0900 Subject: [PATCH 2/3] Bound workspace pattern extractors (#2864) --- DEVELOPER_GUIDE.md | 18 +- README.md | 8 +- changelog.d/unreleased/2864.security.md | 19 + .../ConfiguredSymbolExtractor.cs | 41 +- .../Extensibility/ExtractorPluginRegistry.cs | 417 +++++++++++++++++- tests/CodeIndex.Tests/SymbolExtractorTests.cs | 376 ++++++++++++++++ 6 files changed, 856 insertions(+), 23 deletions(-) create mode 100644 changelog.d/unreleased/2864.security.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index eb7c8c3b11..34c9fe5efe 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -3215,7 +3215,10 @@ Downstream users can add lightweight language support without rebuilding workspace ancestor `.cdidx-langmap.yaml`; workspace entries override user entries; - regex-backed symbol patterns are read from `.cdidx/patterns/*.yaml` and - `~/.config/cdidx/patterns/*.yaml`; + `~/.config/cdidx/patterns/*.yaml`; sidecars must be regular files under + non-symlink pattern directories, each file is capped at 64 KiB / 128 rules, + the process loads at most 128 configured rules total, and regex matches use a + 100 ms timeout; - `cdidx test-extractor --language --file --json` runs symbol extraction without building an index, and `--expect-symbols ` compares the extracted JSON to a fixture. @@ -3240,8 +3243,9 @@ patterns: ``` Each configured regex should expose a named `name` capture. If it does not, -`cdidx` uses the full match text as the symbol name. Invalid sidecar files are -ignored so a broken local experiment does not prevent indexing. +`cdidx` uses the full match text as the symbol name. Invalid, symlinked, +oversized, or over-budget sidecar files are skipped with a stderr diagnostic so +a broken local experiment does not prevent indexing. ## カスタム言語抽出 @@ -3250,10 +3254,16 @@ ignored so a broken local experiment does not prevent indexing. - 拡張子 alias は `~/.config/cdidx/langmap.yaml` と、最初に見つかった workspace 祖先の `.cdidx-langmap.yaml` から読み込まれ、workspace 側が user 側を上書きします。 - regex ベースのシンボルパターンは `.cdidx/patterns/*.yaml` と - `~/.config/cdidx/patterns/*.yaml` から読み込まれます。 + `~/.config/cdidx/patterns/*.yaml` から読み込まれます。sidecar は symlink ではない + pattern directory 配下の通常ファイルのみが対象で、各ファイルは 64 KiB / 128 ルール、 + プロセス全体では configured rule 128 件に制限され、regex match には 100 ms の timeout が付きます。 - `cdidx test-extractor --language --file --json` は index を作らずに symbol extraction だけを実行し、`--expect-symbols ` で fixture JSON と比較できます。 +各 regex は `name` という名前付き capture を公開することを推奨します。存在しない場合、 +`cdidx` は match 全体の文字列を symbol 名として使います。無効、symlink、過大、または +上限超過の sidecar は stderr の診断付きで skip されるため、壊れたローカル実験が indexing を止めません。 + ## SQLite reader のデバッグ `Database/DbDebug.cs` は `ExecuteTrackedReader` / `TrackedRead` の最後に流れた SQL、パラメーター、行ごとの状態を記録し、ループ途中で `SqliteException` が発生した場合に再現に十分な文脈を stderr へダンプする。インデックス済みのソースバイトが想定外の経路に漏れないよう、ダンプ経路はゲート制御されている: diff --git a/README.md b/README.md index 2f13f8e2b9..73aa83f83a 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,9 @@ cdidx lsp --db .cdidx/codeindex.db Custom language loops can stay out of tree: put extension aliases in `.cdidx-langmap.yaml`, put regex symbol patterns in `.cdidx/patterns/*.yaml`, and run `cdidx test-extractor --language --file --json` to test -an extractor fixture without building a full index. See +an extractor fixture without building a full index. Pattern sidecars are +limited to regular files under non-symlink pattern directories, size/count +bounded per file and per process, and regex matches are time-limited. See [Custom Language Extraction](DEVELOPER_GUIDE.md#custom-language-extraction). After the first command, use these cues and follow-up commands: @@ -351,7 +353,9 @@ cdidx lsp --db .cdidx/codeindex.db カスタム言語の開発ループは out-of-tree で回せます。拡張子 alias は `.cdidx-langmap.yaml`、regex シンボルパターンは `.cdidx/patterns/*.yaml` に置き、 `cdidx test-extractor --language --file --json` で full index を作らずに -extractor fixture を確認できます。詳細は +extractor fixture を確認できます。pattern sidecar は symlink ではない pattern directory +配下の通常ファイルだけが対象で、size / count は file 単位と process 単位で制限され、 +regex match には timeout が付きます。詳細は [Custom Language Extraction](DEVELOPER_GUIDE.md#custom-language-extraction) を参照してください。 初回実行後は、次の見方と追加コマンドをよく使います。 diff --git a/changelog.d/unreleased/2864.security.md b/changelog.d/unreleased/2864.security.md new file mode 100644 index 0000000000..135fc0c507 --- /dev/null +++ b/changelog.d/unreleased/2864.security.md @@ -0,0 +1,19 @@ +--- +category: security +issues: + - 2864 +affected: + - src/CodeIndex/Indexer/Extensibility/ConfiguredSymbolExtractor.cs + - src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs + - tests/CodeIndex.Tests/SymbolExtractorTests.cs + - README.md + - DEVELOPER_GUIDE.md +--- + +## English + +- **Workspace pattern extractors are now bounded and time-limited (#2864)** — pattern sidecars must be regular files under non-symlink pattern directories, configs are capped by file size plus per-file and total rule count, invalid or over-budget configs emit clear stderr diagnostics, and timed-out configured regexes are disabled. + +## 日本語 + +- **workspace pattern extractor に上限と timeout を追加しました (#2864)** — pattern sidecar は symlink ではない pattern directory 配下の通常ファイルに限定され、config は file size と file 単位 / total の rule count で制限され、無効または上限超過の config は stderr に明確な診断を出し、timeout した configured regex は無効化されます。 diff --git a/src/CodeIndex/Indexer/Extensibility/ConfiguredSymbolExtractor.cs b/src/CodeIndex/Indexer/Extensibility/ConfiguredSymbolExtractor.cs index 3ea1bba37f..f421e3ded4 100644 --- a/src/CodeIndex/Indexer/Extensibility/ConfiguredSymbolExtractor.cs +++ b/src/CodeIndex/Indexer/Extensibility/ConfiguredSymbolExtractor.cs @@ -8,6 +8,10 @@ internal sealed class ConfiguredSymbolExtractor( IReadOnlyCollection fileExtensions, IReadOnlyList patterns) : ISymbolExtractor { + private readonly object timeoutGate = new(); + private readonly HashSet disabledTimeoutPatterns = []; + private readonly HashSet timeoutWarnings = new(StringComparer.Ordinal); + internal sealed record PatternRule(string Kind, Regex Regex); public string Language { get; } = language; @@ -23,7 +27,20 @@ public IReadOnlyList Extract(long fileId, string source, Extractio lineNumber++; foreach (var pattern in patterns) { - var match = pattern.Regex.Match(line); + if (IsPatternDisabled(pattern)) + continue; + + Match match; + try + { + match = pattern.Regex.Match(line); + } + catch (RegexMatchTimeoutException) + { + DisablePatternAfterTimeout(pattern); + continue; + } + if (!match.Success) continue; @@ -47,4 +64,26 @@ public IReadOnlyList Extract(long fileId, string source, Extractio return symbols; } + + private bool IsPatternDisabled(PatternRule pattern) + { + lock (timeoutGate) + return disabledTimeoutPatterns.Contains(pattern); + } + + private void DisablePatternAfterTimeout(PatternRule pattern) + { + var shouldReport = false; + lock (timeoutGate) + { + disabledTimeoutPatterns.Add(pattern); + shouldReport = timeoutWarnings.Add(pattern.Kind + "\0" + pattern.Regex); + } + + if (!shouldReport) + return; + + Console.Error.WriteLine( + $"[cdidx] Pattern extractor for language '{Language}' kind '{pattern.Kind}' timed out after {(int)ExtractorPluginRegistry.PatternRegexTimeout.TotalMilliseconds}ms; skipped this pattern."); + } } diff --git a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs index 205c7bc61b..efde8cb570 100644 --- a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs +++ b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs @@ -1,6 +1,9 @@ using System.Reflection; +using System.Runtime.InteropServices; using System.Runtime.Loader; +using System.Text; using System.Text.RegularExpressions; +using Microsoft.Win32.SafeHandles; namespace CodeIndex.Indexer.Extensibility; @@ -8,11 +11,17 @@ public static class ExtractorPluginRegistry { public const int CurrentApiVersion = 1; internal const string TrustWorkspacePluginsEnvironmentVariable = "CDIDX_TRUST_WORKSPACE_PLUGINS"; + internal const int MaxPatternConfigBytes = 64 * 1024; + internal const int MaxPatternRulesPerConfig = 128; + internal const int MaxPatternRulesTotal = 128; + internal const int MaxPatternRegexLength = 4096; + internal static readonly TimeSpan PatternRegexTimeout = TimeSpan.FromMilliseconds(100); private static readonly object Gate = new(); private static readonly Dictionary SymbolExtractors = new(StringComparer.Ordinal); private static readonly Dictionary ReferenceExtractors = new(StringComparer.Ordinal); private static readonly HashSet LoadedPatternConfigPaths = new(StringComparer.OrdinalIgnoreCase); + private static int loadedPatternRuleCount; private static bool pluginsLoaded; public static IReadOnlyCollection SymbolLanguages @@ -87,6 +96,7 @@ internal static void ResetForTests() SymbolExtractors.Clear(); ReferenceExtractors.Clear(); LoadedPatternConfigPaths.Clear(); + loadedPatternRuleCount = 0; pluginsLoaded = true; } } @@ -98,6 +108,7 @@ internal static void ReloadForTests() SymbolExtractors.Clear(); ReferenceExtractors.Clear(); LoadedPatternConfigPaths.Clear(); + loadedPatternRuleCount = 0; pluginsLoaded = false; } } @@ -176,28 +187,90 @@ private static IEnumerable EnumeratePluginDirectories() private static IEnumerable EnumeratePatternConfigPaths(string workspaceRoot, bool includeUserDirectory = true) { - foreach (var directory in EnumeratePatternDirectories(workspaceRoot, includeUserDirectory)) + foreach (var path in EnumeratePatternConfigPathsFromDirectory( + Path.Combine(workspaceRoot, ".cdidx", "patterns"), + workspaceRoot)) { - if (!Directory.Exists(directory)) - continue; + yield return path; + } - foreach (var path in Directory.EnumerateFiles(directory, "*.yaml", SearchOption.TopDirectoryOnly)) - yield return path; - foreach (var path in Directory.EnumerateFiles(directory, "*.yml", SearchOption.TopDirectoryOnly)) + if (!includeUserDirectory) + yield break; + + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrWhiteSpace(home)) + { + foreach (var path in EnumeratePatternConfigPathsFromDirectory( + Path.Combine(home, ".config", "cdidx", "patterns"), + workspaceRoot: null)) + { yield return path; + } } } - private static IEnumerable EnumeratePatternDirectories(string workspaceRoot, bool includeUserDirectory) + private static IEnumerable EnumeratePatternConfigPathsFromDirectory(string directory, string? workspaceRoot) { - yield return Path.Combine(workspaceRoot, ".cdidx", "patterns"); + if (!Directory.Exists(directory) || !PatternDirectoryIsSafe(directory, workspaceRoot)) + yield break; - if (!includeUserDirectory) + foreach (var path in EnumeratePatternFiles(directory, "*.yaml")) + yield return path; + foreach (var path in EnumeratePatternFiles(directory, "*.yml")) + yield return path; + } + + private static IEnumerable EnumeratePatternFiles(string directory, string searchPattern) + { + string[] paths; + try + { + paths = Directory.GetFiles(directory, searchPattern, SearchOption.TopDirectoryOnly); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + ReportPatternDirectoryRejected(directory, ex.Message); yield break; + } - var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - if (!string.IsNullOrWhiteSpace(home)) - yield return Path.Combine(home, ".config", "cdidx", "patterns"); + foreach (var path in paths) + yield return path; + } + + private static bool PatternDirectoryIsSafe(string directory, string? workspaceRoot) + { + if (workspaceRoot != null) + { + var workspaceCdidxDirectory = Path.Combine(workspaceRoot, ".cdidx"); + if (DirectoryIsSymlinkOrReparsePoint(workspaceCdidxDirectory)) + { + ReportPatternDirectoryRejected(workspaceCdidxDirectory, "symbolic links and reparse points are not supported"); + return false; + } + } + + if (DirectoryIsSymlinkOrReparsePoint(directory)) + { + ReportPatternDirectoryRejected(directory, "symbolic links and reparse points are not supported"); + return false; + } + + return true; + } + + private static bool DirectoryIsSymlinkOrReparsePoint(string directory) + { + try + { + var info = new DirectoryInfo(directory); + return (info.Attributes & FileAttributes.ReparsePoint) != 0 + || !string.IsNullOrEmpty(info.LinkTarget); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + ReportPatternDirectoryRejected(directory, ex.Message); + return true; + } } private static void TryLoadPatternConfig(string path) @@ -211,11 +284,15 @@ private static void TryLoadPatternConfig(string path) return; } + var configLines = TryReadPatternConfigLines(path); + if (configLines == null) + return; + var language = string.Empty; var extensions = new List(); var patterns = new List(); string? pendingKind = null; - foreach (var rawLine in File.ReadLines(path)) + foreach (var rawLine in configLines) { var line = rawLine.Trim(); if (line.Length == 0 || line.StartsWith('#')) @@ -235,9 +312,38 @@ private static void TryLoadPatternConfig(string path) } else if (TryReadScalar(line.TrimStart('-').Trim(), "regex", out value) && pendingKind != null) { + if (patterns.Count >= MaxPatternRulesPerConfig) + { + ReportPatternConfigRejected(path, $"too many pattern rules (maximum {MaxPatternRulesTotal})"); + return; + } + + if (value.Length > MaxPatternRegexLength) + { + ReportPatternConfigRejected(path, $"regex for kind '{pendingKind}' is too long ({value.Length} characters; maximum {MaxPatternRegexLength})"); + return; + } + + if (!TryReservePatternRuleBudget(path)) + return; + + Regex regex; + try + { + regex = new Regex( + value, + RegexOptions.Compiled | RegexOptions.CultureInvariant, + PatternRegexTimeout); + } + catch (ArgumentException ex) + { + ReportPatternConfigRejected(path, $"invalid regex for kind '{pendingKind}': {ex.Message}"); + return; + } + patterns.Add(new ConfiguredSymbolExtractor.PatternRule( pendingKind, - new Regex(value, RegexOptions.Compiled | RegexOptions.CultureInvariant))); + regex)); pendingKind = null; } } @@ -245,12 +351,291 @@ private static void TryLoadPatternConfig(string path) if (language.Length > 0 && patterns.Count > 0) Register(new ConfiguredSymbolExtractor(language, extensions, patterns)); } - catch + catch (Exception ex) + { + ReportPatternConfigRejected(path, ex.Message); + } + } + + private static void ReportPatternConfigRejected(string path, string reason) + => Console.Error.WriteLine($"[cdidx] Skipped pattern config '{path}': {reason}."); + + private static void ReportPatternDirectoryRejected(string path, string reason) + => Console.Error.WriteLine($"[cdidx] Skipped pattern directory '{path}': {reason}."); + + private static bool TryReservePatternRuleBudget(string path) + { + lock (Gate) + { + if (loadedPatternRuleCount >= MaxPatternRulesTotal) + { + ReportPatternConfigRejected(path, $"too many pattern rules (maximum {MaxPatternRulesTotal})"); + return false; + } + + loadedPatternRuleCount++; + return true; + } + } + + private static IReadOnlyList? TryReadPatternConfigLines(string path) + { + var fileInfo = new FileInfo(path); + if (!fileInfo.Exists) + { + ReportPatternConfigRejected(path, "file does not exist"); + return null; + } + + var attributes = fileInfo.Attributes; + if ((attributes & FileAttributes.Directory) != 0) + { + ReportPatternConfigRejected(path, "path is a directory"); + return null; + } + + if ((attributes & FileAttributes.ReparsePoint) != 0 || !string.IsNullOrEmpty(fileInfo.LinkTarget)) + { + ReportPatternConfigRejected(path, "symbolic links and reparse points are not supported"); + return null; + } + + if (fileInfo.Length > MaxPatternConfigBytes) + { + ReportPatternConfigRejected(path, $"file is too large ({fileInfo.Length} bytes; maximum {MaxPatternConfigBytes})"); + return null; + } + + var bytes = OperatingSystem.IsWindows() + ? TryReadWindowsPatternConfigBytes(path) + : TryReadUnixPatternConfigBytes(path); + if (bytes == null) + return null; + + var text = Encoding.UTF8.GetString(bytes); + return text + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n') + .Split('\n'); + } + + private static byte[]? TryReadWindowsPatternConfigBytes(string path) + { + using var handle = CreateFile( + path, + GenericRead, + FileShare.ReadWrite | FileShare.Delete, + securityAttributes: IntPtr.Zero, + creationDisposition: FileMode.Open, + flagsAndAttributes: FileAttributes.Normal | FileFlagOpenReparsePoint, + templateFile: IntPtr.Zero); + if (handle.IsInvalid) + { + ReportPatternConfigRejected(path, $"could not open safely (errno {Marshal.GetLastPInvokeError()})"); + return null; + } + + if (!GetFileInformationByHandle(handle, out var info)) + { + ReportPatternConfigRejected(path, $"could not inspect file handle (errno {Marshal.GetLastPInvokeError()})"); + return null; + } + + var attributes = (FileAttributes)info.FileAttributes; + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0) + { + ReportPatternConfigRejected(path, "path is not a regular file"); + return null; + } + + var size = ((long)info.FileSizeHigh << 32) | info.FileSizeLow; + if (size > MaxPatternConfigBytes) + { + ReportPatternConfigRejected(path, $"file is too large ({size} bytes; maximum {MaxPatternConfigBytes})"); + return null; + } + + using var stream = new FileStream(handle, FileAccess.Read, bufferSize: 8192, isAsync: false); + return TryReadBoundedPatternConfigBytes(path, stream); + } + + private static byte[]? TryReadUnixPatternConfigBytes(string path) + { + var fd = UnixOpen(path, GetUnixOpenFlags()); + if (fd < 0) + { + ReportPatternConfigRejected(path, $"could not open safely (errno {Marshal.GetLastPInvokeError()})"); + return null; + } + + try + { + if (!TryGetUnixFileType(fd, out var mode) || !IsRegularUnixFile(mode)) + { + ReportPatternConfigRejected(path, "path is not a regular file"); + return null; + } + + using var stream = new MemoryStream(MaxPatternConfigBytes + 1); + var buffer = new byte[Math.Min(8192, MaxPatternConfigBytes + 1)]; + while (stream.Length <= MaxPatternConfigBytes) + { + var remaining = MaxPatternConfigBytes + 1 - (int)stream.Length; + if (remaining <= 0) + break; + + var bytesRead = UnixRead(fd, buffer, (UIntPtr)Math.Min(buffer.Length, remaining)); + if (bytesRead == 0) + break; + if (bytesRead < 0) + { + ReportPatternConfigRejected(path, $"could not read safely (errno {Marshal.GetLastPInvokeError()})"); + return null; + } + + stream.Write(buffer, 0, (int)bytesRead); + } + + return ValidatePatternConfigBytes(path, stream.ToArray()); + } + finally + { + _ = UnixClose(fd); + } + } + + private static byte[]? TryReadBoundedPatternConfigBytes(string path, Stream stream) + { + using var output = new MemoryStream(MaxPatternConfigBytes + 1); + var buffer = new byte[Math.Min(8192, MaxPatternConfigBytes + 1)]; + while (output.Length <= MaxPatternConfigBytes) + { + var remaining = MaxPatternConfigBytes + 1 - (int)output.Length; + if (remaining <= 0) + break; + + var bytesRead = stream.Read(buffer, 0, Math.Min(buffer.Length, remaining)); + if (bytesRead == 0) + break; + + output.Write(buffer, 0, bytesRead); + } + + return ValidatePatternConfigBytes(path, output.ToArray()); + } + + private static byte[]? ValidatePatternConfigBytes(string path, byte[] bytes) + { + if (bytes.Length <= MaxPatternConfigBytes) + return bytes; + + ReportPatternConfigRejected(path, $"file is too large (more than {MaxPatternConfigBytes} bytes)"); + return null; + } + + private static bool TryGetUnixFileType(int fd, out uint mode) + { + mode = 0; + var modeOffset = GetUnixStatModeOffset(); + if (modeOffset < 0) + return false; + + var stat = new byte[UnixStatBufferBytes]; + try + { + if (UnixFStat(fd, stat) != 0) + return false; + + mode = BitConverter.ToUInt32(stat, modeOffset); + return true; + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) { - // Pattern sidecars are best-effort like binary plugins. + return false; } } + internal static int LinuxStatModeOffsetForTests(Architecture architecture) + => LinuxStatModeOffset(architecture); + + private static int GetUnixStatModeOffset() + { + if (OperatingSystem.IsMacOS()) + return 4; + + return OperatingSystem.IsLinux() + ? LinuxStatModeOffset(RuntimeInformation.ProcessArchitecture) + : -1; + } + + private static int LinuxStatModeOffset(Architecture architecture) + => architecture switch + { + Architecture.X64 => 24, + Architecture.Arm64 => 16, + _ => -1, + }; + + private static bool IsRegularUnixFile(uint mode) + { + const uint fileTypeMask = 0xF000; + const uint regularFile = 0x8000; + return (mode & fileTypeMask) == regularFile; + } + + private const uint GenericRead = 0x80000000; + private const FileAttributes FileFlagOpenReparsePoint = (FileAttributes)0x00200000; + private const int UnixStatBufferBytes = 256; + + private static int GetUnixOpenFlags() + { + const int oReadOnly = 0; + if (OperatingSystem.IsMacOS() || OperatingSystem.IsFreeBSD()) + return oReadOnly | 0x0004 | 0x00000100 | 0x01000000; + + return oReadOnly | 0x800 | 0x20000 | 0x80000; + } + + [DllImport("libc", EntryPoint = "open", SetLastError = true)] + private static extern int UnixOpen(string path, int flags); + + [DllImport("libc", EntryPoint = "read", SetLastError = true)] + private static extern nint UnixRead(int fd, byte[] buffer, UIntPtr count); + + [DllImport("libc", EntryPoint = "close", SetLastError = true)] + private static extern int UnixClose(int fd); + + [DllImport("libc", EntryPoint = "fstat", SetLastError = true)] + private static extern int UnixFStat(int fd, [Out] byte[] stat); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string fileName, + uint desiredAccess, + FileShare shareMode, + IntPtr securityAttributes, + [MarshalAs(UnmanagedType.U4)] FileMode creationDisposition, + [MarshalAs(UnmanagedType.U4)] FileAttributes flagsAndAttributes, + IntPtr templateFile); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandle(SafeFileHandle fileHandle, out WindowsFileInformation fileInformation); + + [StructLayout(LayoutKind.Sequential)] + private struct WindowsFileInformation + { + public uint FileAttributes; + public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + private static bool TryReadScalar(string line, string key, out string value) { value = string.Empty; diff --git a/tests/CodeIndex.Tests/SymbolExtractorTests.cs b/tests/CodeIndex.Tests/SymbolExtractorTests.cs index faf2da6261..01ad046b4f 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorTests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorTests.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Collections; using System.Reflection; +using System.Runtime.InteropServices; using System.Text.Json; using CodeIndex.Indexer; using CodeIndex.Indexer.Extensibility; @@ -116,6 +117,372 @@ public void EnumeratePluginAssemblyPaths_SkipsWorkspacePluginsUnlessTrusted() } } + [Fact] + public void Extract_ConfiguredPatternYaml_RejectsInvalidRegexWithDiagnostic() + { + lock (TestConsoleLock.Gate) + { + var tempDir = Path.Combine(Path.GetTempPath(), $"cdidx_patterns_invalid_{Guid.NewGuid():N}"); + try + { + WritePatternConfig( + tempDir, + "language: \"toydsl\"\nextensions:\n - extension: \".toy\"\npatterns:\n - kind: \"class\"\n regex: \"^(?\"\n"); + ExtractorPluginRegistry.ReloadForTests(); + + var stderr = ConsoleCapture.CaptureError(() => + { + var symbols = SymbolExtractor.Extract(2, "toydsl", "entity Widget", "demo.toy", tempDir); + Assert.Empty(symbols); + }); + + Assert.Contains("Skipped pattern config", stderr, StringComparison.Ordinal); + Assert.Contains("invalid regex", stderr, StringComparison.Ordinal); + } + finally + { + ExtractorPluginRegistry.ResetForTests(); + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, recursive: true); + } + } + } + + [Fact] + public void Extract_ConfiguredPatternYaml_RejectsOversizeConfigWithDiagnostic() + { + lock (TestConsoleLock.Gate) + { + var tempDir = Path.Combine(Path.GetTempPath(), $"cdidx_patterns_large_{Guid.NewGuid():N}"); + try + { + WritePatternConfig(tempDir, new string('x', ExtractorPluginRegistry.MaxPatternConfigBytes + 1)); + ExtractorPluginRegistry.ReloadForTests(); + + var stderr = ConsoleCapture.CaptureError(() => + { + var symbols = SymbolExtractor.Extract(2, "toydsl", "entity Widget", "demo.toy", tempDir); + Assert.Empty(symbols); + }); + + Assert.Contains("Skipped pattern config", stderr, StringComparison.Ordinal); + Assert.Contains("file is too large", stderr, StringComparison.Ordinal); + } + finally + { + ExtractorPluginRegistry.ResetForTests(); + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, recursive: true); + } + } + } + + [Fact] + public void Extract_ConfiguredPatternYaml_RejectsSymlinkedConfigWithDiagnostic() + { + lock (TestConsoleLock.Gate) + { + var tempDir = Path.Combine(Path.GetTempPath(), $"cdidx_patterns_symlink_{Guid.NewGuid():N}"); + try + { + var patternDir = Path.Combine(tempDir, ".cdidx", "patterns"); + Directory.CreateDirectory(patternDir); + var targetPath = Path.Combine(tempDir, "target.yaml"); + File.WriteAllText( + targetPath, + "language: \"toydsl\"\nextensions:\n - extension: \".toy\"\npatterns:\n - kind: \"class\"\n regex: \"^entity (?\\\\w+)\"\n"); + var linkPath = Path.Combine(patternDir, "toydsl.yaml"); + try + { + File.CreateSymbolicLink(linkPath, targetPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + ExtractorPluginRegistry.ReloadForTests(); + + var stderr = ConsoleCapture.CaptureError(() => + { + var symbols = SymbolExtractor.Extract(2, "toydsl", "entity Widget", "demo.toy", tempDir); + Assert.Empty(symbols); + }); + + Assert.Contains("Skipped pattern config", stderr, StringComparison.Ordinal); + Assert.Contains("symbolic links", stderr, StringComparison.Ordinal); + } + finally + { + ExtractorPluginRegistry.ResetForTests(); + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, recursive: true); + } + } + } + + [Fact] + public void Extract_ConfiguredPatternYaml_RejectsFifoConfigWithDiagnostic() + { + if (OperatingSystem.IsWindows()) + return; + + lock (TestConsoleLock.Gate) + { + var tempDir = Path.Combine(Path.GetTempPath(), $"cdidx_patterns_fifo_{Guid.NewGuid():N}"); + try + { + var patternDir = Path.Combine(tempDir, ".cdidx", "patterns"); + Directory.CreateDirectory(patternDir); + var fifoPath = Path.Combine(patternDir, "toydsl.yaml"); + try + { + if (Mkfifo(fifoPath, 0x180) != 0) + return; + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) + { + return; + } + + ExtractorPluginRegistry.ReloadForTests(); + + var stderr = ConsoleCapture.CaptureError(() => + { + var symbols = SymbolExtractor.Extract(2, "toydsl", "entity Widget", "demo.toy", tempDir); + Assert.Empty(symbols); + }); + + Assert.Contains("Skipped pattern config", stderr, StringComparison.Ordinal); + Assert.Contains("not a regular file", stderr, StringComparison.Ordinal); + } + finally + { + ExtractorPluginRegistry.ResetForTests(); + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, recursive: true); + } + } + } + + [Fact] + public void Extract_ConfiguredPatternYaml_RejectsSymlinkedPatternDirectoryWithDiagnostic() + { + lock (TestConsoleLock.Gate) + { + var tempDir = Path.Combine(Path.GetTempPath(), $"cdidx_patterns_dir_symlink_{Guid.NewGuid():N}"); + try + { + var externalPatternDir = Path.Combine(tempDir, "external-patterns"); + Directory.CreateDirectory(externalPatternDir); + File.WriteAllText( + Path.Combine(externalPatternDir, "toydsl.yaml"), + "language: \"toydsl\"\nextensions:\n - extension: \".toy\"\npatterns:\n - kind: \"class\"\n regex: \"^entity (?\\\\w+)\"\n"); + + var cdidxDir = Path.Combine(tempDir, ".cdidx"); + Directory.CreateDirectory(cdidxDir); + var patternDir = Path.Combine(cdidxDir, "patterns"); + try + { + Directory.CreateSymbolicLink(patternDir, externalPatternDir); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + ExtractorPluginRegistry.ReloadForTests(); + + var stderr = ConsoleCapture.CaptureError(() => + { + var symbols = SymbolExtractor.Extract(2, "toydsl", "entity Widget", "demo.toy", tempDir); + Assert.Empty(symbols); + }); + + Assert.Contains("Skipped pattern directory", stderr, StringComparison.Ordinal); + Assert.Contains("symbolic links", stderr, StringComparison.Ordinal); + } + finally + { + ExtractorPluginRegistry.ResetForTests(); + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, recursive: true); + } + } + } + + [Fact] + public void Extract_ConfiguredPatternYaml_RejectsSymlinkedCdidxPatternParentWithDiagnostic() + { + lock (TestConsoleLock.Gate) + { + var tempDir = Path.Combine(Path.GetTempPath(), $"cdidx_patterns_parent_symlink_{Guid.NewGuid():N}"); + try + { + Directory.CreateDirectory(tempDir); + var externalCdidxDir = Path.Combine(tempDir, "external-cdidx"); + var externalPatternDir = Path.Combine(externalCdidxDir, "patterns"); + Directory.CreateDirectory(externalPatternDir); + File.WriteAllText( + Path.Combine(externalPatternDir, "toydsl.yaml"), + "language: \"toydsl\"\nextensions:\n - extension: \".toy\"\npatterns:\n - kind: \"class\"\n regex: \"^entity (?\\\\w+)\"\n"); + + var cdidxDir = Path.Combine(tempDir, ".cdidx"); + try + { + Directory.CreateSymbolicLink(cdidxDir, externalCdidxDir); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + ExtractorPluginRegistry.ReloadForTests(); + + var stderr = ConsoleCapture.CaptureError(() => + { + var symbols = SymbolExtractor.Extract(2, "toydsl", "entity Widget", "demo.toy", tempDir); + Assert.Empty(symbols); + }); + + Assert.Contains("Skipped pattern directory", stderr, StringComparison.Ordinal); + Assert.Contains("symbolic links", stderr, StringComparison.Ordinal); + } + finally + { + ExtractorPluginRegistry.ResetForTests(); + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, recursive: true); + } + } + } + + [Fact] + public void Extract_ConfiguredPatternYaml_RejectsExcessPatternCountWithDiagnostic() + { + lock (TestConsoleLock.Gate) + { + var tempDir = Path.Combine(Path.GetTempPath(), $"cdidx_patterns_count_{Guid.NewGuid():N}"); + try + { + var rules = string.Join( + "\n", + Enumerable.Range(0, ExtractorPluginRegistry.MaxPatternRulesPerConfig + 1) + .Select(i => $" - kind: \"class{i}\"\n regex: \"^entity{i} (?\\\\w+)\"")); + WritePatternConfig( + tempDir, + $"language: \"toydsl\"\nextensions:\n - extension: \".toy\"\npatterns:\n{rules}\n"); + ExtractorPluginRegistry.ReloadForTests(); + + var stderr = ConsoleCapture.CaptureError(() => + { + var symbols = SymbolExtractor.Extract(2, "toydsl", "entity0 Widget", "demo.toy", tempDir); + Assert.Empty(symbols); + }); + + Assert.Contains("Skipped pattern config", stderr, StringComparison.Ordinal); + Assert.Contains("too many pattern rules", stderr, StringComparison.Ordinal); + } + finally + { + ExtractorPluginRegistry.ResetForTests(); + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, recursive: true); + } + } + } + + [Fact] + public void Extract_ConfiguredPatternYaml_RejectsAggregatePatternCountWithDiagnostic() + { + lock (TestConsoleLock.Gate) + { + var tempDir = Path.Combine(Path.GetTempPath(), $"cdidx_patterns_total_count_{Guid.NewGuid():N}"); + try + { + var rules = string.Join( + "\n", + Enumerable.Range(0, ExtractorPluginRegistry.MaxPatternRulesTotal) + .Select(i => $" - kind: \"class{i}\"\n regex: \"^entity{i} (?\\\\w+)\"")); + WritePatternConfig( + tempDir, + "first.yaml", + $"language: \"toydsl\"\nextensions:\n - extension: \".toy\"\npatterns:\n{rules}\n"); + WritePatternConfig( + tempDir, + "second.yaml", + "language: \"toydsl\"\nextensions:\n - extension: \".toy\"\npatterns:\n - kind: \"overflow\"\n regex: \"^never (?\\\\w+)\"\n - kind: \"overflow2\"\n regex: \"^alsoNever (?\\\\w+)\"\n"); + ExtractorPluginRegistry.ReloadForTests(); + + var stderr = ConsoleCapture.CaptureError(() => + { + var symbols = SymbolExtractor.Extract(2, "toydsl", "entityOverflow Widget", "demo.toy", tempDir); + Assert.Empty(symbols); + }); + + Assert.Contains("Skipped pattern config", stderr, StringComparison.Ordinal); + Assert.Contains("too many pattern rules", stderr, StringComparison.Ordinal); + } + finally + { + ExtractorPluginRegistry.ResetForTests(); + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, recursive: true); + } + } + } + + [Fact] + public void Extract_ConfiguredPatternYaml_DisablesPatternAfterRegexTimeout() + { + lock (TestConsoleLock.Gate) + { + var tempDir = Path.Combine(Path.GetTempPath(), $"cdidx_patterns_timeout_{Guid.NewGuid():N}"); + try + { + WritePatternConfig( + tempDir, + "language: \"toydsl\"\nextensions:\n - extension: \".toy\"\npatterns:\n - kind: \"class\"\n regex: \"^(a+)+$\"\n"); + ExtractorPluginRegistry.ReloadForTests(); + const int extractionCount = 25; + var slowLine = new string('a', 10_000) + "!"; + + var stopwatch = Stopwatch.StartNew(); + var stderr = ConsoleCapture.CaptureError(() => + { + for (var i = 0; i < extractionCount; i++) + { + var symbols = SymbolExtractor.Extract(2, "toydsl", slowLine, $"demo{i}.toy", tempDir); + Assert.Empty(symbols); + } + }); + stopwatch.Stop(); + + var timeoutDiagnostics = stderr + .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) + .Where(line => line.Contains("timed out", StringComparison.Ordinal)) + .ToArray(); + Assert.Single(timeoutDiagnostics); + Assert.True( + stopwatch.Elapsed < TimeSpan.FromSeconds(1), + $"Expected timed-out pattern to be disabled after the first extraction timeout, elapsed {stopwatch.Elapsed}."); + } + finally + { + ExtractorPluginRegistry.ResetForTests(); + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, recursive: true); + } + } + } + + [Fact] + public void Extract_ConfiguredPatternYaml_LinuxStatModeOffsetsCoverReleaseArchitectures() + { + Assert.Equal(24, ExtractorPluginRegistry.LinuxStatModeOffsetForTests(Architecture.X64)); + Assert.Equal(16, ExtractorPluginRegistry.LinuxStatModeOffsetForTests(Architecture.Arm64)); + } + [Fact] public void Extract_CsharpFileScopedNamespace_DoesNotEnterMemberHeaderMerge() { @@ -25794,4 +26161,13 @@ private static string WriteFile(string projectRoot, string relativePath, string File.WriteAllText(path, content); return path; } + + private static void WritePatternConfig(string projectRoot, string content) + => WritePatternConfig(projectRoot, "toydsl.yaml", content); + + private static void WritePatternConfig(string projectRoot, string fileName, string content) + => WriteFile(projectRoot, Path.Combine(".cdidx", "patterns", fileName), content); + + [DllImport("libc", EntryPoint = "mkfifo", SetLastError = true)] + private static extern int Mkfifo(string path, uint mode); } From 5a2e21e3f762e1adcf0ad1986a5dd333cf1f3452 Mon Sep 17 00:00:00 2001 From: Widthdom <125688807+Widthdom@users.noreply.github.com> Date: Wed, 3 Jun 2026 01:14:51 +0900 Subject: [PATCH 3/3] Resolve extractor registry conflict --- .../Extensibility/ExtractorPluginRegistry.cs | 179 ++++++++++++++++-- 1 file changed, 168 insertions(+), 11 deletions(-) diff --git a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs index efde8cb570..985cb2fd92 100644 --- a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs +++ b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs @@ -2,6 +2,7 @@ using System.Runtime.InteropServices; using System.Runtime.Loader; using System.Text; +using System.Text.Json.Serialization; using System.Text.RegularExpressions; using Microsoft.Win32.SafeHandles; @@ -21,6 +22,12 @@ public static class ExtractorPluginRegistry private static readonly Dictionary SymbolExtractors = new(StringComparer.Ordinal); private static readonly Dictionary ReferenceExtractors = new(StringComparer.Ordinal); private static readonly HashSet LoadedPatternConfigPaths = new(StringComparer.OrdinalIgnoreCase); + private static readonly List Diagnostics = []; + private const int DiagnosticLimit = 20; + private static int pluginAssemblyCount; + private static int patternConfigCount; + private static int skippedFileCount; + private static int diagnosticTotalCount; private static int loadedPatternRuleCount; private static bool pluginsLoaded; @@ -73,6 +80,26 @@ public static bool TryGetReferenceExtractor(string language, out IReferenceExtra return ReferenceExtractors.TryGetValue(language, out extractor!); } + internal static ExtractorRegistryStatus GetStatusSnapshot() + { + EnsurePluginsLoaded(); + lock (Gate) + { + return new ExtractorRegistryStatus + { + PluginAssemblyCount = pluginAssemblyCount, + PatternConfigCount = patternConfigCount, + SymbolExtractorCount = SymbolExtractors.Count, + ReferenceExtractorCount = ReferenceExtractors.Count, + SkippedFileCount = skippedFileCount, + DiagnosticCount = diagnosticTotalCount, + DiagnosticLimit = DiagnosticLimit, + DiagnosticsTruncated = diagnosticTotalCount > Diagnostics.Count, + Diagnostics = Diagnostics.Count == 0 ? null : Diagnostics.ToList(), + }; + } + } + public static void Register(ISymbolExtractor extractor) { ArgumentNullException.ThrowIfNull(extractor); @@ -96,6 +123,11 @@ internal static void ResetForTests() SymbolExtractors.Clear(); ReferenceExtractors.Clear(); LoadedPatternConfigPaths.Clear(); + Diagnostics.Clear(); + pluginAssemblyCount = 0; + patternConfigCount = 0; + skippedFileCount = 0; + diagnosticTotalCount = 0; loadedPatternRuleCount = 0; pluginsLoaded = true; } @@ -108,6 +140,11 @@ internal static void ReloadForTests() SymbolExtractors.Clear(); ReferenceExtractors.Clear(); LoadedPatternConfigPaths.Clear(); + Diagnostics.Clear(); + pluginAssemblyCount = 0; + patternConfigCount = 0; + skippedFileCount = 0; + diagnosticTotalCount = 0; loadedPatternRuleCount = 0; pluginsLoaded = false; } @@ -349,7 +386,15 @@ private static void TryLoadPatternConfig(string path) } if (language.Length > 0 && patterns.Count > 0) + { Register(new ConfiguredSymbolExtractor(language, extensions, patterns)); + lock (Gate) + patternConfigCount++; + } + else + { + ReportPatternConfigSkipped(path, "missing language or regex patterns"); + } } catch (Exception ex) { @@ -358,10 +403,40 @@ private static void TryLoadPatternConfig(string path) } private static void ReportPatternConfigRejected(string path, string reason) - => Console.Error.WriteLine($"[cdidx] Skipped pattern config '{path}': {reason}."); + { + Console.Error.WriteLine($"[cdidx] Skipped pattern config '{path}': {reason}."); + RecordDiagnostic( + "pattern", + path, + typeName: null, + severity: "error", + $"Pattern config skipped: {reason}", + countsAsSkippedFile: true); + } + + private static void ReportPatternConfigSkipped(string path, string reason) + { + Console.Error.WriteLine($"[cdidx] Skipped pattern config '{path}': {reason}."); + RecordDiagnostic( + "pattern", + path, + typeName: null, + severity: "skipped", + $"Pattern config skipped: {reason}", + countsAsSkippedFile: true); + } private static void ReportPatternDirectoryRejected(string path, string reason) - => Console.Error.WriteLine($"[cdidx] Skipped pattern directory '{path}': {reason}."); + { + Console.Error.WriteLine($"[cdidx] Skipped pattern directory '{path}': {reason}."); + RecordDiagnostic( + "pattern_directory", + path, + typeName: null, + severity: "error", + $"Pattern directory skipped: {reason}", + countsAsSkippedFile: false); + } private static bool TryReservePatternRuleBudget(string path) { @@ -648,30 +723,59 @@ private static bool TryReadScalar(string line, string key, out string value) private static void TryLoadPlugin(string pluginPath) { + var fullPath = pluginPath; try { - var assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(Path.GetFullPath(pluginPath)); + fullPath = Path.GetFullPath(pluginPath); + var assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(fullPath); var attribute = assembly.GetCustomAttribute(); - if (attribute == null - || attribute.MinApiVersion > CurrentApiVersion + if (attribute == null) + { + RecordDiagnostic( + "plugin", + fullPath, + typeName: null, + severity: "skipped", + "Plugin assembly skipped: missing CdidxPluginAttribute.", + countsAsSkippedFile: true); + return; + } + + if (attribute.MinApiVersion > CurrentApiVersion || attribute.MaxApiVersion < CurrentApiVersion) { + RecordDiagnostic( + "plugin", + fullPath, + typeName: null, + severity: "skipped", + $"Plugin assembly skipped: API range {attribute.MinApiVersion}-{attribute.MaxApiVersion} does not include {CurrentApiVersion}.", + countsAsSkippedFile: true); return; } + lock (Gate) + pluginAssemblyCount++; + foreach (var type in assembly.GetTypes()) { if (type is { IsAbstract: false, IsInterface: false } && type.GetConstructor(Type.EmptyTypes) != null) - TryRegisterPluginType(type); + TryRegisterPluginType(type, fullPath); } } - catch + catch (Exception ex) { - // Plugin loading is best-effort so an incompatible DLL cannot prevent indexing. + RecordDiagnostic( + "plugin", + fullPath, + typeName: null, + severity: "error", + $"Failed to load plugin assembly: {ex.Message}", + countsAsSkippedFile: true); } } - private static void TryRegisterPluginType(Type type) + private static void TryRegisterPluginType(Type type, string pluginPath) { try { @@ -687,9 +791,33 @@ private static void TryRegisterPluginType(Type type) Register(referenceExtractor); } } - catch + catch (Exception ex) + { + RecordDiagnostic( + "plugin_type", + pluginPath, + type.FullName, + severity: "error", + $"Failed to instantiate plugin type: {ex.Message}", + countsAsSkippedFile: false); + } + } + + private static void RecordDiagnostic( + string kind, + string path, + string? typeName, + string severity, + string message, + bool countsAsSkippedFile) + { + lock (Gate) { - // Ignore broken plugin types and continue loading the rest of the assembly. + diagnosticTotalCount++; + if (countsAsSkippedFile) + skippedFileCount++; + if (Diagnostics.Count < DiagnosticLimit) + Diagnostics.Add(new ExtractorRegistryDiagnostic(kind, path, typeName, severity, message)); } } @@ -736,3 +864,32 @@ private static string NormalizePluginLanguage(string language) return extension.StartsWith(".", StringComparison.Ordinal) ? extension : "." + extension; } } + +public sealed class ExtractorRegistryStatus +{ + [JsonPropertyName("plugin_assembly_count")] + public int PluginAssemblyCount { get; init; } + [JsonPropertyName("pattern_config_count")] + public int PatternConfigCount { get; init; } + [JsonPropertyName("symbol_extractor_count")] + public int SymbolExtractorCount { get; init; } + [JsonPropertyName("reference_extractor_count")] + public int ReferenceExtractorCount { get; init; } + [JsonPropertyName("skipped_file_count")] + public int SkippedFileCount { get; init; } + [JsonPropertyName("diagnostic_count")] + public int DiagnosticCount { get; init; } + [JsonPropertyName("diagnostic_limit")] + public int DiagnosticLimit { get; init; } + [JsonPropertyName("diagnostics_truncated")] + public bool DiagnosticsTruncated { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Diagnostics { get; init; } +} + +public sealed record ExtractorRegistryDiagnostic( + [property: JsonPropertyName("kind")] string Kind, + [property: JsonPropertyName("path")] string Path, + [property: JsonPropertyName("type_name")] string? TypeName, + [property: JsonPropertyName("severity")] string Severity, + [property: JsonPropertyName("message")] string Message);