Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions changelog.d/unreleased/3052.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: security
issues:
- 3052
affected:
- src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs
- tests/CodeIndex.Tests/SymbolExtractorConfiguredPatternTests.cs
---

## English

- **Pattern config loading now avoids newline split arrays (#3052)** — configured extractor YAML is parsed line-by-line from bounded UTF-8 text, avoiding whole-file `Split('\n')` allocations for configs with many tiny lines.

## 日本語

- **pattern config 読み込みで改行 Split 配列を作らないようにしました (#3052)** — configured extractor YAML を上限付き UTF-8 テキストから行ごとに解析し、小さな行が多い config でファイル全体の `Split('\n')` 割り当てを避けるようにしました。
79 changes: 62 additions & 17 deletions src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -430,33 +430,35 @@ private static void TryLoadPatternConfig(string path)
return;
}

var configLines = TryReadPatternConfigLines(path);
if (configLines == null)
var configText = TryReadPatternConfigText(path);
if (configText == null)
return;

var language = string.Empty;
var extensions = new List<string>();
var patterns = new List<ConfiguredSymbolExtractor.PatternRule>();
string? pendingKind = null;
foreach (var rawLine in configLines)
var remaining = configText.AsSpan();
while (TryReadNextPatternConfigLine(ref remaining, out var rawLine))
{
var line = rawLine.Trim();
if (line.Length == 0 || line.StartsWith('#'))
if (line.Length == 0 || line[0] == '#')
continue;

var itemLine = TrimPatternConfigListMarker(line);
if (TryReadScalar(line, "language", out var value))
{
language = NormalizePluginLanguage(value);
}
else if (TryReadScalar(line.TrimStart('-').Trim(), "extension", out value))
else if (TryReadScalar(itemLine, "extension", out value))
{
extensions.Add(NormalizePluginExtension(value) ?? value);
}
else if (TryReadScalar(line.TrimStart('-').Trim(), "kind", out value))
else if (TryReadScalar(itemLine, "kind", out value))
{
pendingKind = value.Trim();
}
else if (TryReadScalar(line.TrimStart('-').Trim(), "regex", out value) && pendingKind != null)
else if (TryReadScalar(itemLine, "regex", out value) && pendingKind != null)
{
if (patterns.Count >= MaxPatternRulesPerConfig)
{
Expand Down Expand Up @@ -573,7 +575,7 @@ private static bool TryReservePatternRuleBudget(string path)
}
}

private static IReadOnlyList<string>? TryReadPatternConfigLines(string path)
private static string? TryReadPatternConfigText(string path)
{
var fileInfo = new FileInfo(path);
if (!fileInfo.Exists)
Expand Down Expand Up @@ -607,11 +609,38 @@ private static bool TryReservePatternRuleBudget(string 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');
return Encoding.UTF8.GetString(bytes);
}

private static bool TryReadNextPatternConfigLine(ref ReadOnlySpan<char> remaining, out ReadOnlySpan<char> line)
{
if (remaining.IsEmpty)
{
line = default;
return false;
}

var lineBreakIndex = remaining.IndexOfAny('\r', '\n');
if (lineBreakIndex < 0)
{
line = remaining;
remaining = default;
return true;
}

line = remaining[..lineBreakIndex];
var nextIndex = lineBreakIndex + 1;
if (remaining[lineBreakIndex] == '\r' && nextIndex < remaining.Length && remaining[nextIndex] == '\n')
nextIndex++;
remaining = remaining[nextIndex..];
return true;
}

private static ReadOnlySpan<char> TrimPatternConfigListMarker(ReadOnlySpan<char> line)
{
while (!line.IsEmpty && line[0] == '-')
line = line[1..];
return line.Trim();
}

private static byte[]? TryReadWindowsPatternConfigBytes(string path)
Expand Down Expand Up @@ -831,16 +860,32 @@ private struct WindowsFileInformation
public uint FileIndexLow;
}

private static bool TryReadScalar(string line, string key, out string value)
private static bool TryReadScalar(ReadOnlySpan<char> line, string key, out string value)
{
value = string.Empty;
var prefix = key + ":";
if (!line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
if (line.Length <= key.Length || line[key.Length] != ':')
return false;
value = line[prefix.Length..].Trim().Trim('"', '\'').Replace("\\\\", "\\", StringComparison.Ordinal);

if (!line.StartsWith(key.AsSpan(), StringComparison.OrdinalIgnoreCase))
return false;

var scalar = TrimScalarQuotes(line[(key.Length + 1)..].Trim());
if (scalar.IsEmpty)
return false;

value = scalar.ToString().Replace("\\\\", "\\", StringComparison.Ordinal);
return value.Length > 0;
}

private static ReadOnlySpan<char> TrimScalarQuotes(ReadOnlySpan<char> value)
{
while (!value.IsEmpty && (value[0] == '"' || value[0] == '\''))
value = value[1..];
while (!value.IsEmpty && (value[^1] == '"' || value[^1] == '\''))
value = value[..^1];
return value;
}

private static void TryLoadPlugin(string pluginPath)
{
var fullPath = pluginPath;
Expand Down
29 changes: 29 additions & 0 deletions tests/CodeIndex.Tests/SymbolExtractorConfiguredPatternTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,35 @@ public void Extract_ConfiguredPatternYaml_RejectsInvalidRegexWithDiagnostic()
}
}

[Fact]
public void Extract_ConfiguredPatternYaml_ReadsManyLineConfig()
{
lock (TestConsoleLock.Gate)
{
var tempDir = Path.Combine(Path.GetTempPath(), $"cdidx_patterns_many_lines_{Guid.NewGuid():N}");
try
{
var commentLines = string.Join("\n", Enumerable.Repeat("#", 4096));
WritePatternConfig(
tempDir,
$"{commentLines}\nlanguage: \"toydsl\"\nextensions:\n - extension: \".toy\"\npatterns:\n - kind: \"class\"\n regex: \"^entity (?<name>\\\\w+)\"\n");
ExtractorPluginRegistry.ReloadForTests();

var symbols = SymbolExtractor.Extract(2, "toydsl", "entity Widget", "demo.toy", tempDir);

var symbol = Assert.Single(symbols);
Assert.Equal("class", symbol.Kind);
Assert.Equal("Widget", symbol.Name);
}
finally
{
ExtractorPluginRegistry.ResetForTests();
if (Directory.Exists(tempDir))
Directory.Delete(tempDir, recursive: true);
}
}
}

[Fact]
public void Extract_ConfiguredPatternYaml_RejectsOversizeConfigWithDiagnostic()
{
Expand Down
Loading