diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md
index bb0753aeb1..9e3c66e05b 100644
--- a/DEVELOPER_GUIDE.md
+++ b/DEVELOPER_GUIDE.md
@@ -1223,7 +1223,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.
@@ -3251,7 +3251,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.
@@ -3276,8 +3279,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.
## カスタム言語抽出
@@ -3286,10 +3290,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 c5ccebd387..6e880b37d2 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:
@@ -358,7 +360,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/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/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 b0b179e911..985cb2fd92 100644
--- a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs
+++ b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs
@@ -1,13 +1,22 @@
using System.Reflection;
+using System.Runtime.InteropServices;
using System.Runtime.Loader;
+using System.Text;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
+using Microsoft.Win32.SafeHandles;
namespace CodeIndex.Indexer.Extensibility;
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);
@@ -19,6 +28,7 @@ public static class ExtractorPluginRegistry
private static int patternConfigCount;
private static int skippedFileCount;
private static int diagnosticTotalCount;
+ private static int loadedPatternRuleCount;
private static bool pluginsLoaded;
public static IReadOnlyCollection SymbolLanguages
@@ -118,6 +128,7 @@ internal static void ResetForTests()
patternConfigCount = 0;
skippedFileCount = 0;
diagnosticTotalCount = 0;
+ loadedPatternRuleCount = 0;
pluginsLoaded = true;
}
}
@@ -134,10 +145,14 @@ internal static void ReloadForTests()
patternConfigCount = 0;
skippedFileCount = 0;
diagnosticTotalCount = 0;
+ loadedPatternRuleCount = 0;
pluginsLoaded = false;
}
}
+ internal static IReadOnlyList EnumeratePluginAssemblyPathsForTests()
+ => EnumeratePluginAssemblyPaths().ToArray();
+
internal static void LoadPatternConfigsForProjectRoot(string? projectRoot)
{
EnsurePluginsLoaded();
@@ -199,7 +214,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))
@@ -208,47 +224,112 @@ 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)
{
- var fullPath = path;
try
{
- fullPath = Path.GetFullPath(path);
+ path = Path.GetFullPath(path);
lock (Gate)
{
- if (!LoadedPatternConfigPaths.Add(fullPath))
+ if (!LoadedPatternConfigPaths.Add(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('#'))
@@ -268,9 +349,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;
}
}
@@ -283,27 +393,324 @@ private static void TryLoadPatternConfig(string path)
}
else
{
- RecordDiagnostic(
- "pattern",
- fullPath,
- typeName: null,
- severity: "skipped",
- "Pattern config skipped: missing language or regex patterns.",
- countsAsSkippedFile: true);
+ ReportPatternConfigSkipped(path, "missing language or regex patterns");
}
}
catch (Exception ex)
{
- RecordDiagnostic(
- "pattern",
- fullPath,
- typeName: null,
- severity: "error",
- $"Failed to load pattern config: {ex.Message}",
- countsAsSkippedFile: true);
+ ReportPatternConfigRejected(path, ex.Message);
+ }
+ }
+
+ private static void ReportPatternConfigRejected(string path, string 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}.");
+ RecordDiagnostic(
+ "pattern_directory",
+ path,
+ typeName: null,
+ severity: "error",
+ $"Pattern directory skipped: {reason}",
+ countsAsSkippedFile: false);
+ }
+
+ 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)
+ {
+ 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;
@@ -414,6 +821,16 @@ private static void RecordDiagnostic(
}
}
+ 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 3590c6dc57..c441834070 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.RegularExpressions;
using System.Text.Json;
using CodeIndex.Indexer;
@@ -119,6 +120,409 @@ 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_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()
{
@@ -25893,4 +26297,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);
}