From 824947d591cb764395e8ed92a196da0717df2f99 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 15 Jun 2026 08:37:02 +0900 Subject: [PATCH] Split extractor plugin registry for #3422 --- changelog.d/unreleased/3422.internal.md | 19 + .../ExtractorPluginRegistry.Diagnostics.cs | 126 ++ .../ExtractorPluginRegistry.Discovery.cs | 231 ++++ .../ExtractorPluginRegistry.PatternConfig.cs | 486 ++++++++ .../ExtractorPluginRegistry.PluginLoading.cs | 193 ++++ .../Extensibility/ExtractorPluginRegistry.cs | 1018 +---------------- 6 files changed, 1056 insertions(+), 1017 deletions(-) create mode 100644 changelog.d/unreleased/3422.internal.md create mode 100644 src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.Diagnostics.cs create mode 100644 src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.Discovery.cs create mode 100644 src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.PatternConfig.cs create mode 100644 src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.PluginLoading.cs diff --git a/changelog.d/unreleased/3422.internal.md b/changelog.d/unreleased/3422.internal.md new file mode 100644 index 0000000000..40073f92d1 --- /dev/null +++ b/changelog.d/unreleased/3422.internal.md @@ -0,0 +1,19 @@ +--- +category: internal +issues: + - 3422 +affected: + - src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs + - src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.Diagnostics.cs + - src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.Discovery.cs + - src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.PatternConfig.cs + - src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.PluginLoading.cs +--- + +## English + +- **Split extractor plugin registry responsibilities (#3422)** — `ExtractorPluginRegistry` now keeps discovery, pattern config handling, plugin assembly loading, and diagnostic reporting in focused partial files while preserving the existing extension behavior. + +## 日本語 + +- **extractor plugin registry の責務を分割しました (#3422)** — `ExtractorPluginRegistry` は既存の extension 挙動を維持したまま、探索、pattern config 処理、plugin assembly loading、diagnostic reporting を責務別の partial file に分けました。 diff --git a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.Diagnostics.cs b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.Diagnostics.cs new file mode 100644 index 0000000000..10d5ceb17c --- /dev/null +++ b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.Diagnostics.cs @@ -0,0 +1,126 @@ +using System.Text.Json.Serialization; +using CodeIndex.Diagnostics; + +namespace CodeIndex.Indexer.Extensibility; + +public static partial class ExtractorPluginRegistry +{ + private static void ReportPatternConfigRejected(string path, string reason) + { + Console.Error.WriteLine($"[cdidx] Skipped pattern config '{DiagnosticSanitizer.ForPath(path)}': {DiagnosticSanitizer.ForMessage(reason)}."); + RecordDiagnostic( + "pattern", + path, + typeName: null, + severity: "error", + $"Pattern config skipped: {reason}", + countsAsSkippedFile: true, + category: "invalid_pattern_config"); + } + + private static void ReportPatternConfigSkipped(string path, string reason) + { + Console.Error.WriteLine($"[cdidx] Skipped pattern config '{DiagnosticSanitizer.ForPath(path)}': {DiagnosticSanitizer.ForMessage(reason)}."); + RecordDiagnostic( + "pattern", + path, + typeName: null, + severity: "skipped", + $"Pattern config skipped: {reason}", + countsAsSkippedFile: true, + category: "pattern_config_incomplete"); + } + + private static void ReportPatternDirectoryRejected(string path, string reason) + { + Console.Error.WriteLine($"[cdidx] Skipped pattern directory '{DiagnosticSanitizer.ForPath(path)}': {DiagnosticSanitizer.ForMessage(reason)}."); + RecordDiagnostic( + "pattern_directory", + path, + typeName: null, + severity: "error", + $"Pattern directory skipped: {reason}", + countsAsSkippedFile: false, + category: "pattern_directory_rejected"); + } + + private static void ReportPatternDirectorySkipped(string path, string reason) + { + Console.Error.WriteLine($"[cdidx] Skipped pattern directory '{DiagnosticSanitizer.ForPath(path)}': {DiagnosticSanitizer.ForMessage(reason)}."); + RecordDiagnostic( + "pattern_directory", + path, + typeName: null, + severity: "skipped", + $"Pattern directory skipped: {reason}.", + countsAsSkippedFile: false, + category: "pattern_candidate_limit_exceeded"); + } + + private static void ReportPluginDirectorySkipped(string path, string reason, string category) + { + RecordDiagnostic( + "plugin_directory", + path, + typeName: null, + severity: "skipped", + $"Plugin directory skipped: {reason}.", + countsAsSkippedFile: false, + category: category); + } + + private static void RecordDiagnostic( + string kind, + string path, + string? typeName, + string severity, + string message, + bool countsAsSkippedFile, + string category = "unspecified") + { + lock (Gate) + { + diagnosticTotalCount++; + if (countsAsSkippedFile) + skippedFileCount++; + if (Diagnostics.Count < DiagnosticLimit) + Diagnostics.Add(new ExtractorRegistryDiagnostic( + DiagnosticSanitizer.ForMessage(kind), + DiagnosticSanitizer.ForPath(path), + DiagnosticSanitizer.ForOptionalLabel(typeName), + DiagnosticSanitizer.ForMessage(severity), + DiagnosticSanitizer.ForMessage(category), + DiagnosticSanitizer.ForMessage(message))); + } + } +} + +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("category")] string Category, + [property: JsonPropertyName("message")] string Message); diff --git a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.Discovery.cs b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.Discovery.cs new file mode 100644 index 0000000000..55fc123cf8 --- /dev/null +++ b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.Discovery.cs @@ -0,0 +1,231 @@ +namespace CodeIndex.Indexer.Extensibility; + +public static partial class ExtractorPluginRegistry +{ + private static IEnumerable EnumeratePluginAssemblyPaths() + => EnumeratePluginAssemblyPaths(EnumeratePluginDirectories(projectRoot: null)); + + private static IEnumerable EnumeratePluginAssemblyPaths(IEnumerable directories) + { + var totalCandidates = 0; + foreach (var directory in directories) + { + if (!Directory.Exists(directory)) + continue; + + using var enumerator = TryEnumeratePluginFiles(directory); + if (enumerator == null) + continue; + + var directoryCandidates = 0; + while (TryMoveNextPluginFile(directory, enumerator, out var pluginPath)) + { + if (directoryCandidates >= MaxPluginAssemblyCandidatesPerDirectory) + { + ReportPluginDirectorySkipped( + directory, + $"too many plugin assembly candidates (maximum {MaxPluginAssemblyCandidatesPerDirectory} per directory)", + "plugin_candidate_limit_exceeded"); + break; + } + + if (totalCandidates >= MaxPluginAssemblyCandidatesTotal) + { + ReportPluginDirectorySkipped( + directory, + $"too many plugin assembly candidates (maximum {MaxPluginAssemblyCandidatesTotal} total)", + "plugin_candidate_limit_exceeded"); + yield break; + } + + directoryCandidates++; + totalCandidates++; + yield return pluginPath; + } + } + } + + private static IEnumerator? TryEnumeratePluginFiles(string directory) + { + try + { + return Directory.EnumerateFiles(directory, "*.dll", SearchOption.TopDirectoryOnly).GetEnumerator(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + ReportPluginDirectorySkipped(directory, "could not enumerate plugin directory", "plugin_directory_enumeration_failed"); + return null; + } + } + + private static bool TryMoveNextPluginFile(string directory, IEnumerator enumerator, out string pluginPath) + { + pluginPath = string.Empty; + try + { + if (!enumerator.MoveNext()) + return false; + + pluginPath = enumerator.Current; + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + ReportPluginDirectorySkipped(directory, "could not enumerate plugin directory", "plugin_directory_enumeration_failed"); + return false; + } + } + + private static IEnumerable EnumeratePluginDirectories(string? projectRoot) + { + if (WorkspacePluginsTrusted() && !string.IsNullOrWhiteSpace(projectRoot)) + { + foreach (var directory in EnumerateWorkspacePluginDirectories(Path.GetFullPath(projectRoot))) + yield return directory; + } + + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrWhiteSpace(home)) + yield return Path.Combine(home, ".cdidx", "plugins"); + } + + private static IEnumerable EnumerateWorkspacePluginDirectories(string projectRoot) + { + yield return Path.Combine(projectRoot, ".cdidx", "plugins"); + } + + private static IEnumerable EnumeratePatternConfigPaths(string workspaceRoot, bool includeUserDirectory = true) + { + foreach (var path in EnumeratePatternConfigPathsFromDirectory( + Path.Combine(workspaceRoot, ".cdidx", "patterns"), + workspaceRoot)) + { + yield return path; + } + + if (!includeUserDirectory) + yield break; + + foreach (var path in EnumerateUserPatternConfigPaths()) + yield return path; + } + + private static IEnumerable EnumerateUserPatternConfigPaths() + { + 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 EnumeratePatternConfigPathsFromDirectory(string directory, string? workspaceRoot) + { + if (!Directory.Exists(directory) || !PatternDirectoryIsSafe(directory, workspaceRoot)) + yield break; + + var directoryCandidates = 0; + foreach (var searchPattern in PatternConfigSearchPatterns) + { + using var enumerator = TryEnumeratePatternFiles(directory, searchPattern); + if (enumerator == null) + continue; + + while (TryMoveNextPatternFile(directory, enumerator, out var path)) + { + if (directoryCandidates >= MaxPatternConfigCandidatesPerDirectory) + { + ReportPatternDirectorySkipped( + directory, + $"too many pattern config candidates (maximum {MaxPatternConfigCandidatesPerDirectory} per directory)"); + yield break; + } + + directoryCandidates++; + yield return path; + } + } + } + + private static IEnumerator? TryEnumeratePatternFiles(string directory, string searchPattern) + { + try + { + return Directory.EnumerateFiles(directory, searchPattern, SearchOption.TopDirectoryOnly).GetEnumerator(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + ReportPatternDirectoryRejected(directory, "could not enumerate pattern directory"); + return null; + } + } + + private static bool TryMoveNextPatternFile(string directory, IEnumerator enumerator, out string patternPath) + { + patternPath = string.Empty; + try + { + if (!enumerator.MoveNext()) + return false; + + patternPath = enumerator.Current; + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + ReportPatternDirectoryRejected(directory, "could not enumerate pattern directory"); + return false; + } + } + + 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, "could not inspect pattern directory"); + return true; + } + } + + 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)); + } +} diff --git a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.PatternConfig.cs b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.PatternConfig.cs new file mode 100644 index 0000000000..63eee892e0 --- /dev/null +++ b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.PatternConfig.cs @@ -0,0 +1,486 @@ +using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; +using CodeIndex.Diagnostics; +using Microsoft.Win32.SafeHandles; + +namespace CodeIndex.Indexer.Extensibility; + +public static partial class ExtractorPluginRegistry +{ + private static void TryLoadPatternConfig(string path) + { + try + { + path = Path.GetFullPath(path); + lock (Gate) + { + if (!LoadedPatternConfigPaths.Add(path)) + return; + } + + var configText = TryReadPatternConfigText(path); + if (configText == null) + return; + + var language = string.Empty; + var extensions = new List(); + var patterns = new List(); + string? pendingKind = null; + var remaining = configText.AsSpan(); + while (TryReadNextPatternConfigLine(ref remaining, out var rawLine)) + { + var line = rawLine.Trim(); + if (line.Length == 0 || line[0] == '#') + continue; + + var itemLine = TrimPatternConfigListMarker(line); + var scalarResult = TryReadScalar(line, "language", MaxPatternLanguageLength, out var value, out var scalarLength); + if (scalarResult == PatternScalarReadResult.TooLong) + { + ReportPatternConfigRejected(path, $"language scalar is too long ({scalarLength} characters; maximum {MaxPatternLanguageLength})"); + return; + } + + if (scalarResult == PatternScalarReadResult.Success) + { + language = NormalizePluginLanguage(value); + } + else + { + scalarResult = TryReadScalar(itemLine, "extension", MaxPatternExtensionLength, out value, out scalarLength); + if (scalarResult == PatternScalarReadResult.TooLong) + { + ReportPatternConfigRejected(path, $"extension scalar is too long ({scalarLength} characters; maximum {MaxPatternExtensionLength})"); + return; + } + + if (scalarResult == PatternScalarReadResult.Success) + { + var extension = NormalizePluginExtension(value) ?? value; + if (extension.Length > MaxPatternExtensionLength) + { + ReportPatternConfigRejected(path, $"extension scalar is too long ({extension.Length} characters; maximum {MaxPatternExtensionLength})"); + return; + } + + extensions.Add(extension); + } + else + { + scalarResult = TryReadScalar(itemLine, "kind", MaxPatternKindLength, out value, out scalarLength); + if (scalarResult == PatternScalarReadResult.TooLong) + { + ReportPatternConfigRejected(path, $"kind scalar is too long ({scalarLength} characters; maximum {MaxPatternKindLength})"); + return; + } + + if (scalarResult == PatternScalarReadResult.Success) + { + pendingKind = value.Trim(); + } + else if (TryReadScalar(itemLine, "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) + { + ReportPatternConfigRejected(path, $"invalid regex for kind '{DiagnosticSanitizer.ForMessage(pendingKind)}'"); + return; + } + + patterns.Add(new ConfiguredSymbolExtractor.PatternRule( + pendingKind, + regex)); + pendingKind = null; + } + } + } + } + + 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) + { + ReportPatternConfigRejected(path, "could not parse pattern config"); + } + } + + 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 string? TryReadPatternConfigText(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; + + return Encoding.UTF8.GetString(bytes); + } + + private static bool TryReadNextPatternConfigLine(ref ReadOnlySpan remaining, out ReadOnlySpan 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 TrimPatternConfigListMarker(ReadOnlySpan line) + { + while (!line.IsEmpty && line[0] == '-') + line = line[1..]; + return line.Trim(); + } + + 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 enum PatternScalarReadResult + { + Missing, + Empty, + TooLong, + Success, + } + + private static bool TryReadScalar(ReadOnlySpan line, string key, out string value) + => TryReadScalar(line, key, int.MaxValue, out value, out _) == PatternScalarReadResult.Success; + + private static PatternScalarReadResult TryReadScalar( + ReadOnlySpan line, + string key, + int maxLength, + out string value, + out int scalarLength) + { + value = string.Empty; + scalarLength = 0; + if (line.Length <= key.Length || line[key.Length] != ':') + return PatternScalarReadResult.Missing; + + if (!line.StartsWith(key.AsSpan(), StringComparison.OrdinalIgnoreCase)) + return PatternScalarReadResult.Missing; + + var scalar = TrimScalarQuotes(line[(key.Length + 1)..].Trim()); + if (scalar.IsEmpty) + return PatternScalarReadResult.Empty; + + value = scalar.ToString().Replace("\\\\", "\\", StringComparison.Ordinal); + scalarLength = value.Length; + if (scalarLength == 0) + return PatternScalarReadResult.Empty; + return scalarLength > maxLength + ? PatternScalarReadResult.TooLong + : PatternScalarReadResult.Success; + } + + private static ReadOnlySpan TrimScalarQuotes(ReadOnlySpan value) + { + while (!value.IsEmpty && (value[0] == '"' || value[0] == '\'')) + value = value[1..]; + while (!value.IsEmpty && (value[^1] == '"' || value[^1] == '\'')) + value = value[..^1]; + return value; + } + + 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; + } +} diff --git a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.PluginLoading.cs b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.PluginLoading.cs new file mode 100644 index 0000000000..091bf2bdff --- /dev/null +++ b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.PluginLoading.cs @@ -0,0 +1,193 @@ +using System.Reflection; + +namespace CodeIndex.Indexer.Extensibility; + +public static partial class ExtractorPluginRegistry +{ + private static void LoadPluginAssemblies(IEnumerable directories) + { + var pluginPaths = EnumeratePluginAssemblyPaths(directories).ToArray(); + foreach (var pluginPath in pluginPaths) + TryLoadPlugin(pluginPath); + } + + private static void TryLoadPlugin(string pluginPath) + { + var fullPath = pluginPath; + ExtensionAssemblyLoadContext? loadContext = null; + try + { + fullPath = Path.GetFullPath(pluginPath); + lock (Gate) + { + if (!LoadedPluginAssemblyPaths.Add(fullPath)) + return; + } + + if (!PluginAssemblyCandidateIsWithinBudget(fullPath)) + return; + + loadContext = new ExtensionAssemblyLoadContext( + $"cdidx-plugin:{Path.GetFileNameWithoutExtension(fullPath)}", + fullPath); + var assembly = loadContext.LoadFromAssemblyPath(fullPath); + var attribute = assembly.GetCustomAttribute(); + if (attribute == null) + { + RecordDiagnostic( + "plugin", + fullPath, + typeName: null, + severity: "skipped", + "Plugin assembly skipped: missing CdidxPluginAttribute.", + countsAsSkippedFile: true, + category: "missing_plugin_attribute"); + 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, + category: "incompatible_plugin_api"); + return; + } + + lock (Gate) + { + pluginAssemblyCount++; + LoadedPluginAssemblyContexts.Add(loadContext); + loadContext = null; + } + + foreach (var type in assembly.GetTypes()) + { + if (type is { IsAbstract: false, IsInterface: false } && type.GetConstructor(Type.EmptyTypes) != null) + TryRegisterPluginType(type, fullPath); + } + } + catch (Exception) + { + RecordDiagnostic( + "plugin", + fullPath, + typeName: null, + severity: "error", + "Failed to load plugin assembly.", + countsAsSkippedFile: true, + category: "assembly_load_failed"); + } + finally + { + loadContext?.Unload(); + } + } + + private static void UnloadPluginAssemblyContexts() + { + foreach (var loadContext in LoadedPluginAssemblyContexts) + { + if (loadContext.IsCollectible) + loadContext.Unload(); + } + + LoadedPluginAssemblyContexts.Clear(); + } + + private static bool PluginAssemblyCandidateIsWithinBudget(string fullPath) + { + FileInfo fileInfo; + try + { + fileInfo = new FileInfo(fullPath); + } + catch (Exception ex) when (ex is ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException) + { + RecordDiagnostic( + "plugin", + fullPath, + typeName: null, + severity: "error", + "Plugin assembly skipped: could not inspect file.", + countsAsSkippedFile: true, + category: "plugin_file_inspection_failed"); + return false; + } + + if (!fileInfo.Exists) + { + RecordDiagnostic( + "plugin", + fullPath, + typeName: null, + severity: "error", + "Plugin assembly skipped: file does not exist.", + countsAsSkippedFile: true, + category: "plugin_file_missing"); + return false; + } + + if ((fileInfo.Attributes & FileAttributes.Directory) != 0) + { + RecordDiagnostic( + "plugin", + fullPath, + typeName: null, + severity: "error", + "Plugin assembly skipped: path is a directory.", + countsAsSkippedFile: true, + category: "plugin_path_is_directory"); + return false; + } + + if (fileInfo.Length > MaxPluginAssemblyBytes) + { + RecordDiagnostic( + "plugin", + fullPath, + typeName: null, + severity: "skipped", + $"Plugin assembly skipped: file is too large ({fileInfo.Length} bytes; maximum {MaxPluginAssemblyBytes}).", + countsAsSkippedFile: true, + category: "plugin_file_too_large"); + return false; + } + + return true; + } + + private static void TryRegisterPluginType(Type type, string pluginPath) + { + try + { + if (typeof(ISymbolExtractor).IsAssignableFrom(type) + && Activator.CreateInstance(type) is ISymbolExtractor symbolExtractor) + { + Register(symbolExtractor); + } + + if (typeof(IReferenceExtractor).IsAssignableFrom(type) + && Activator.CreateInstance(type) is IReferenceExtractor referenceExtractor) + { + Register(referenceExtractor); + } + } + catch (Exception) + { + RecordDiagnostic( + "plugin_type", + pluginPath, + type.FullName, + severity: "error", + "Failed to instantiate plugin type.", + countsAsSkippedFile: false, + category: "plugin_type_instantiation_failed"); + } + } +} diff --git a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs index 14aa315d4b..853c55ea56 100644 --- a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs +++ b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs @@ -1,15 +1,8 @@ -using System.Reflection; -using System.Runtime.InteropServices; using System.Runtime.Loader; -using System.Text; -using System.Text.Json.Serialization; -using System.Text.RegularExpressions; -using CodeIndex.Diagnostics; -using Microsoft.Win32.SafeHandles; namespace CodeIndex.Indexer.Extensibility; -public static class ExtractorPluginRegistry +public static partial class ExtractorPluginRegistry { public const int CurrentApiVersion = 1; internal const string TrustWorkspacePluginsEnvironmentVariable = "CDIDX_TRUST_WORKSPACE_PLUGINS"; @@ -245,985 +238,6 @@ private static void EnsurePluginsLoaded() } } - private static void LoadPluginAssemblies(IEnumerable directories) - { - var pluginPaths = EnumeratePluginAssemblyPaths(directories).ToArray(); - foreach (var pluginPath in pluginPaths) - TryLoadPlugin(pluginPath); - } - - private static IEnumerable EnumeratePluginAssemblyPaths() - => EnumeratePluginAssemblyPaths(EnumeratePluginDirectories(projectRoot: null)); - - private static IEnumerable EnumeratePluginAssemblyPaths(IEnumerable directories) - { - var totalCandidates = 0; - foreach (var directory in directories) - { - if (!Directory.Exists(directory)) - continue; - - using var enumerator = TryEnumeratePluginFiles(directory); - if (enumerator == null) - continue; - - var directoryCandidates = 0; - while (TryMoveNextPluginFile(directory, enumerator, out var pluginPath)) - { - if (directoryCandidates >= MaxPluginAssemblyCandidatesPerDirectory) - { - ReportPluginDirectorySkipped( - directory, - $"too many plugin assembly candidates (maximum {MaxPluginAssemblyCandidatesPerDirectory} per directory)", - "plugin_candidate_limit_exceeded"); - break; - } - - if (totalCandidates >= MaxPluginAssemblyCandidatesTotal) - { - ReportPluginDirectorySkipped( - directory, - $"too many plugin assembly candidates (maximum {MaxPluginAssemblyCandidatesTotal} total)", - "plugin_candidate_limit_exceeded"); - yield break; - } - - directoryCandidates++; - totalCandidates++; - yield return pluginPath; - } - } - } - - private static IEnumerator? TryEnumeratePluginFiles(string directory) - { - try - { - return Directory.EnumerateFiles(directory, "*.dll", SearchOption.TopDirectoryOnly).GetEnumerator(); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - ReportPluginDirectorySkipped(directory, "could not enumerate plugin directory", "plugin_directory_enumeration_failed"); - return null; - } - } - - private static bool TryMoveNextPluginFile(string directory, IEnumerator enumerator, out string pluginPath) - { - pluginPath = string.Empty; - try - { - if (!enumerator.MoveNext()) - return false; - - pluginPath = enumerator.Current; - return true; - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - ReportPluginDirectorySkipped(directory, "could not enumerate plugin directory", "plugin_directory_enumeration_failed"); - return false; - } - } - - private static IEnumerable EnumeratePluginDirectories(string? projectRoot) - { - if (WorkspacePluginsTrusted() && !string.IsNullOrWhiteSpace(projectRoot)) - { - foreach (var directory in EnumerateWorkspacePluginDirectories(Path.GetFullPath(projectRoot))) - yield return directory; - } - - var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - if (!string.IsNullOrWhiteSpace(home)) - yield return Path.Combine(home, ".cdidx", "plugins"); - } - - private static IEnumerable EnumerateWorkspacePluginDirectories(string projectRoot) - { - yield return Path.Combine(projectRoot, ".cdidx", "plugins"); - } - - private static IEnumerable EnumeratePatternConfigPaths(string workspaceRoot, bool includeUserDirectory = true) - { - foreach (var path in EnumeratePatternConfigPathsFromDirectory( - Path.Combine(workspaceRoot, ".cdidx", "patterns"), - workspaceRoot)) - { - yield return path; - } - - if (!includeUserDirectory) - yield break; - - foreach (var path in EnumerateUserPatternConfigPaths()) - yield return path; - } - - private static IEnumerable EnumerateUserPatternConfigPaths() - { - 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 EnumeratePatternConfigPathsFromDirectory(string directory, string? workspaceRoot) - { - if (!Directory.Exists(directory) || !PatternDirectoryIsSafe(directory, workspaceRoot)) - yield break; - - var directoryCandidates = 0; - foreach (var searchPattern in PatternConfigSearchPatterns) - { - using var enumerator = TryEnumeratePatternFiles(directory, searchPattern); - if (enumerator == null) - continue; - - while (TryMoveNextPatternFile(directory, enumerator, out var path)) - { - if (directoryCandidates >= MaxPatternConfigCandidatesPerDirectory) - { - ReportPatternDirectorySkipped( - directory, - $"too many pattern config candidates (maximum {MaxPatternConfigCandidatesPerDirectory} per directory)"); - yield break; - } - - directoryCandidates++; - yield return path; - } - } - } - - private static IEnumerator? TryEnumeratePatternFiles(string directory, string searchPattern) - { - try - { - return Directory.EnumerateFiles(directory, searchPattern, SearchOption.TopDirectoryOnly).GetEnumerator(); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - ReportPatternDirectoryRejected(directory, "could not enumerate pattern directory"); - return null; - } - } - - private static bool TryMoveNextPatternFile(string directory, IEnumerator enumerator, out string patternPath) - { - patternPath = string.Empty; - try - { - if (!enumerator.MoveNext()) - return false; - - patternPath = enumerator.Current; - return true; - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - ReportPatternDirectoryRejected(directory, "could not enumerate pattern directory"); - return false; - } - } - - 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, "could not inspect pattern directory"); - return true; - } - } - - private static void TryLoadPatternConfig(string path) - { - try - { - path = Path.GetFullPath(path); - lock (Gate) - { - if (!LoadedPatternConfigPaths.Add(path)) - return; - } - - var configText = TryReadPatternConfigText(path); - if (configText == null) - return; - - var language = string.Empty; - var extensions = new List(); - var patterns = new List(); - string? pendingKind = null; - var remaining = configText.AsSpan(); - while (TryReadNextPatternConfigLine(ref remaining, out var rawLine)) - { - var line = rawLine.Trim(); - if (line.Length == 0 || line[0] == '#') - continue; - - var itemLine = TrimPatternConfigListMarker(line); - var scalarResult = TryReadScalar(line, "language", MaxPatternLanguageLength, out var value, out var scalarLength); - if (scalarResult == PatternScalarReadResult.TooLong) - { - ReportPatternConfigRejected(path, $"language scalar is too long ({scalarLength} characters; maximum {MaxPatternLanguageLength})"); - return; - } - - if (scalarResult == PatternScalarReadResult.Success) - { - language = NormalizePluginLanguage(value); - } - else - { - scalarResult = TryReadScalar(itemLine, "extension", MaxPatternExtensionLength, out value, out scalarLength); - if (scalarResult == PatternScalarReadResult.TooLong) - { - ReportPatternConfigRejected(path, $"extension scalar is too long ({scalarLength} characters; maximum {MaxPatternExtensionLength})"); - return; - } - - if (scalarResult == PatternScalarReadResult.Success) - { - var extension = NormalizePluginExtension(value) ?? value; - if (extension.Length > MaxPatternExtensionLength) - { - ReportPatternConfigRejected(path, $"extension scalar is too long ({extension.Length} characters; maximum {MaxPatternExtensionLength})"); - return; - } - - extensions.Add(extension); - } - else - { - scalarResult = TryReadScalar(itemLine, "kind", MaxPatternKindLength, out value, out scalarLength); - if (scalarResult == PatternScalarReadResult.TooLong) - { - ReportPatternConfigRejected(path, $"kind scalar is too long ({scalarLength} characters; maximum {MaxPatternKindLength})"); - return; - } - - if (scalarResult == PatternScalarReadResult.Success) - { - pendingKind = value.Trim(); - } - else if (TryReadScalar(itemLine, "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) - { - ReportPatternConfigRejected(path, $"invalid regex for kind '{DiagnosticSanitizer.ForMessage(pendingKind)}'"); - return; - } - - patterns.Add(new ConfiguredSymbolExtractor.PatternRule( - pendingKind, - regex)); - pendingKind = null; - } - } - } - } - - 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) - { - ReportPatternConfigRejected(path, "could not parse pattern config"); - } - } - - private static void ReportPatternConfigRejected(string path, string reason) - { - Console.Error.WriteLine($"[cdidx] Skipped pattern config '{DiagnosticSanitizer.ForPath(path)}': {DiagnosticSanitizer.ForMessage(reason)}."); - RecordDiagnostic( - "pattern", - path, - typeName: null, - severity: "error", - $"Pattern config skipped: {reason}", - countsAsSkippedFile: true, - category: "invalid_pattern_config"); - } - - private static void ReportPatternConfigSkipped(string path, string reason) - { - Console.Error.WriteLine($"[cdidx] Skipped pattern config '{DiagnosticSanitizer.ForPath(path)}': {DiagnosticSanitizer.ForMessage(reason)}."); - RecordDiagnostic( - "pattern", - path, - typeName: null, - severity: "skipped", - $"Pattern config skipped: {reason}", - countsAsSkippedFile: true, - category: "pattern_config_incomplete"); - } - - private static void ReportPatternDirectoryRejected(string path, string reason) - { - Console.Error.WriteLine($"[cdidx] Skipped pattern directory '{DiagnosticSanitizer.ForPath(path)}': {DiagnosticSanitizer.ForMessage(reason)}."); - RecordDiagnostic( - "pattern_directory", - path, - typeName: null, - severity: "error", - $"Pattern directory skipped: {reason}", - countsAsSkippedFile: false, - category: "pattern_directory_rejected"); - } - - private static void ReportPatternDirectorySkipped(string path, string reason) - { - Console.Error.WriteLine($"[cdidx] Skipped pattern directory '{DiagnosticSanitizer.ForPath(path)}': {DiagnosticSanitizer.ForMessage(reason)}."); - RecordDiagnostic( - "pattern_directory", - path, - typeName: null, - severity: "skipped", - $"Pattern directory skipped: {reason}.", - countsAsSkippedFile: false, - category: "pattern_candidate_limit_exceeded"); - } - - private static void ReportPluginDirectorySkipped(string path, string reason, string category) - { - RecordDiagnostic( - "plugin_directory", - path, - typeName: null, - severity: "skipped", - $"Plugin directory skipped: {reason}.", - countsAsSkippedFile: false, - category: category); - } - - 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 string? TryReadPatternConfigText(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; - - return Encoding.UTF8.GetString(bytes); - } - - private static bool TryReadNextPatternConfigLine(ref ReadOnlySpan remaining, out ReadOnlySpan 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 TrimPatternConfigListMarker(ReadOnlySpan line) - { - while (!line.IsEmpty && line[0] == '-') - line = line[1..]; - return line.Trim(); - } - - 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 enum PatternScalarReadResult - { - Missing, - Empty, - TooLong, - Success, - } - - private static bool TryReadScalar(ReadOnlySpan line, string key, out string value) - => TryReadScalar(line, key, int.MaxValue, out value, out _) == PatternScalarReadResult.Success; - - private static PatternScalarReadResult TryReadScalar( - ReadOnlySpan line, - string key, - int maxLength, - out string value, - out int scalarLength) - { - value = string.Empty; - scalarLength = 0; - if (line.Length <= key.Length || line[key.Length] != ':') - return PatternScalarReadResult.Missing; - - if (!line.StartsWith(key.AsSpan(), StringComparison.OrdinalIgnoreCase)) - return PatternScalarReadResult.Missing; - - var scalar = TrimScalarQuotes(line[(key.Length + 1)..].Trim()); - if (scalar.IsEmpty) - return PatternScalarReadResult.Empty; - - value = scalar.ToString().Replace("\\\\", "\\", StringComparison.Ordinal); - scalarLength = value.Length; - if (scalarLength == 0) - return PatternScalarReadResult.Empty; - return scalarLength > maxLength - ? PatternScalarReadResult.TooLong - : PatternScalarReadResult.Success; - } - - private static ReadOnlySpan TrimScalarQuotes(ReadOnlySpan 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; - ExtensionAssemblyLoadContext? loadContext = null; - try - { - fullPath = Path.GetFullPath(pluginPath); - lock (Gate) - { - if (!LoadedPluginAssemblyPaths.Add(fullPath)) - return; - } - - if (!PluginAssemblyCandidateIsWithinBudget(fullPath)) - return; - - loadContext = new ExtensionAssemblyLoadContext( - $"cdidx-plugin:{Path.GetFileNameWithoutExtension(fullPath)}", - fullPath); - var assembly = loadContext.LoadFromAssemblyPath(fullPath); - var attribute = assembly.GetCustomAttribute(); - if (attribute == null) - { - RecordDiagnostic( - "plugin", - fullPath, - typeName: null, - severity: "skipped", - "Plugin assembly skipped: missing CdidxPluginAttribute.", - countsAsSkippedFile: true, - category: "missing_plugin_attribute"); - 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, - category: "incompatible_plugin_api"); - return; - } - - lock (Gate) - { - pluginAssemblyCount++; - LoadedPluginAssemblyContexts.Add(loadContext); - loadContext = null; - } - - foreach (var type in assembly.GetTypes()) - { - if (type is { IsAbstract: false, IsInterface: false } && type.GetConstructor(Type.EmptyTypes) != null) - TryRegisterPluginType(type, fullPath); - } - } - catch (Exception) - { - RecordDiagnostic( - "plugin", - fullPath, - typeName: null, - severity: "error", - "Failed to load plugin assembly.", - countsAsSkippedFile: true, - category: "assembly_load_failed"); - } - finally - { - loadContext?.Unload(); - } - } - - private static void UnloadPluginAssemblyContexts() - { - foreach (var loadContext in LoadedPluginAssemblyContexts) - { - if (loadContext.IsCollectible) - loadContext.Unload(); - } - - LoadedPluginAssemblyContexts.Clear(); - } - - private static bool PluginAssemblyCandidateIsWithinBudget(string fullPath) - { - FileInfo fileInfo; - try - { - fileInfo = new FileInfo(fullPath); - } - catch (Exception ex) when (ex is ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException) - { - RecordDiagnostic( - "plugin", - fullPath, - typeName: null, - severity: "error", - "Plugin assembly skipped: could not inspect file.", - countsAsSkippedFile: true, - category: "plugin_file_inspection_failed"); - return false; - } - - if (!fileInfo.Exists) - { - RecordDiagnostic( - "plugin", - fullPath, - typeName: null, - severity: "error", - "Plugin assembly skipped: file does not exist.", - countsAsSkippedFile: true, - category: "plugin_file_missing"); - return false; - } - - if ((fileInfo.Attributes & FileAttributes.Directory) != 0) - { - RecordDiagnostic( - "plugin", - fullPath, - typeName: null, - severity: "error", - "Plugin assembly skipped: path is a directory.", - countsAsSkippedFile: true, - category: "plugin_path_is_directory"); - return false; - } - - if (fileInfo.Length > MaxPluginAssemblyBytes) - { - RecordDiagnostic( - "plugin", - fullPath, - typeName: null, - severity: "skipped", - $"Plugin assembly skipped: file is too large ({fileInfo.Length} bytes; maximum {MaxPluginAssemblyBytes}).", - countsAsSkippedFile: true, - category: "plugin_file_too_large"); - return false; - } - - return true; - } - - private static void TryRegisterPluginType(Type type, string pluginPath) - { - try - { - if (typeof(ISymbolExtractor).IsAssignableFrom(type) - && Activator.CreateInstance(type) is ISymbolExtractor symbolExtractor) - { - Register(symbolExtractor); - } - - if (typeof(IReferenceExtractor).IsAssignableFrom(type) - && Activator.CreateInstance(type) is IReferenceExtractor referenceExtractor) - { - Register(referenceExtractor); - } - } - catch (Exception) - { - RecordDiagnostic( - "plugin_type", - pluginPath, - type.FullName, - severity: "error", - "Failed to instantiate plugin type.", - countsAsSkippedFile: false, - category: "plugin_type_instantiation_failed"); - } - } - - private static void RecordDiagnostic( - string kind, - string path, - string? typeName, - string severity, - string message, - bool countsAsSkippedFile, - string category = "unspecified") - { - lock (Gate) - { - diagnosticTotalCount++; - if (countsAsSkippedFile) - skippedFileCount++; - if (Diagnostics.Count < DiagnosticLimit) - Diagnostics.Add(new ExtractorRegistryDiagnostic( - DiagnosticSanitizer.ForMessage(kind), - DiagnosticSanitizer.ForPath(path), - DiagnosticSanitizer.ForOptionalLabel(typeName), - DiagnosticSanitizer.ForMessage(severity), - DiagnosticSanitizer.ForMessage(category), - DiagnosticSanitizer.ForMessage(message))); - } - } - - 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) @@ -1257,33 +271,3 @@ 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("category")] string Category, - [property: JsonPropertyName("message")] string Message);