diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 04e381cb24..0a7e1359be 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1970,9 +1970,25 @@ server over stdio. It reuses the existing CodeIndex database and exposes `initialize`, `workspace/symbol`, `textDocument/documentSymbol`, `textDocument/definition`, and `textDocument/references` for editors that can launch an arbitrary LSP command but do not speak MCP. -Incoming `textDocument.uri` values are rejected before URI parsing when they -exceed 4096 characters, matching the MCP resource URI limit and keeping error -responses bounded. +Incoming `textDocument.uri` values must be strings, must be absolute `file:` +URIs, and are rejected before URI parsing when they exceed 4096 characters, +matching the MCP resource URI limit and keeping error responses bounded. LSP +frame parsing also rejects more than 64 header lines, more than 65536 aggregate +header bytes, any one header line above 8192 bytes, duplicate `Content-Length` +headers, or a body above 8388608 bytes before reading the message body. +Unknown-method diagnostics echo at most 240 method-name characters with `...` +when the method name is longer. Request IDs must be bounded JSON-RPC scalar +values: strings are capped at 256 +characters, integer IDs must fit in `Int64`, and non-scalar IDs are rejected as +invalid requests before response IDs are cloned. `workspace/symbol` query +strings are capped at 1000 characters before symbol search runs. +`textDocument/documentSymbol` returns at most 1000 indexed symbols, truncates +each `detail` string to 512 characters with `...`, and stops adding symbols +before the result array exceeds 524288 JSON bytes. +Position-based `definition` and `references` lookups read at most 16384 +characters from the target source line before returning an empty result. +When exact indexed path resolution misses, LSP document path fallback inspects +at most 32 basename candidates before treating the document as unresolved. Tool results include structured JSON in `structuredContent` plus a short text summary in `content`, so AI tools can parse typed data without scraping large text blocks. @@ -4237,8 +4253,24 @@ cdidxには**MCP(Model Context Protocol)サーバー**が組み込まれて 任意の LSP command を起動できるが MCP には対応していない editor 向けに `initialize`、`workspace/symbol`、`textDocument/documentSymbol`、 `textDocument/definition`、`textDocument/references` を公開します。 -受信した `textDocument.uri` は 4096 文字を超える場合、URI parse の前に拒否されます。 -これは MCP resource URI の上限と揃えており、エラー応答が過大にならないようにします。 +受信した `textDocument.uri` は string かつ absolute `file:` URI である必要があり、 +4096 文字を超える場合は URI parse の前に拒否されます。これは MCP resource URI の上限と +揃えており、エラー応答が過大にならないようにします。 +LSP frame parsing は、message body を読む前に 64 行を超える header、合計 65536 bytes を +超える header、8192 bytes を超える単一 header 行、重複した `Content-Length` header、 +8388608 bytes を超える body を拒否します。 +method-not-found diagnostic で echo する method name は最大 240 文字に制限され、 +長い場合は `...` を付けて切り詰めます。 +request ID は bounded な JSON-RPC scalar value に限定され、string は 256 文字まで、 +integer ID は `Int64` に収まるものだけを受理し、non-scalar ID は response ID を複製する前に +invalid request として拒否します。 +`workspace/symbol` の query string は symbol search を実行する前に 1000 文字で上限をかけます。 +`textDocument/documentSymbol` は最大 1000 件の indexed symbol を返し、各 `detail` string を +`...` 付きの 512 文字に切り詰め、result array が 524288 JSON bytes を超える前に symbol 追加を止めます。 +position-based な `definition` / `references` lookup は、対象 source line を最大 16384 文字まで読み、 +超過時は空の result を返します。 +exact indexed path resolution が失敗した場合、LSP document path fallback は最大 32 件の +basename candidate だけを確認し、見つからなければ unresolved document として扱います。 ツール結果は `structuredContent` に構造化JSON、`content` に短い要約テキストを返すため、AIツールは巨大なテキストをパースせずに型付きデータを扱えます。 diff --git a/changelog.d/unreleased/3128.security.md b/changelog.d/unreleased/3128.security.md new file mode 100644 index 0000000000..e55f21537f --- /dev/null +++ b/changelog.d/unreleased/3128.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3128 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **LSP `workspace/symbol` queries now enforce the shared query length limit (#3128)** — oversized symbol queries are rejected before database search work starts. + +## 日本語 + +- **LSP `workspace/symbol` query に共通の query 長上限を適用しました (#3128)** — 過大な symbol query は database search を開始する前に拒否されます。 diff --git a/changelog.d/unreleased/3130.security.md b/changelog.d/unreleased/3130.security.md new file mode 100644 index 0000000000..a7968f061d --- /dev/null +++ b/changelog.d/unreleased/3130.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3130 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **LSP `documentSymbol` responses now bound detail and response size (#3130)** — symbol details are truncated with an explicit marker and response arrays stop before exceeding the JSON byte budget. + +## 日本語 + +- **LSP `documentSymbol` response の detail と response size に上限を追加しました (#3130)** — symbol detail は明示的な marker 付きで切り詰め、response array は JSON byte 予算を超える前に打ち切ります。 diff --git a/changelog.d/unreleased/3136.security.md b/changelog.d/unreleased/3136.security.md new file mode 100644 index 0000000000..60a4f4eddf --- /dev/null +++ b/changelog.d/unreleased/3136.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3136 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **LSP position lookups now cap target line length (#3136)** — definition and references requests stop reading oversized source lines before token extraction and return an empty result. + +## 日本語 + +- **LSP position lookup の対象行長に上限を追加しました (#3136)** — definition / references request は token extraction の前に過大な source line の読み取りを止め、空の result を返します。 diff --git a/changelog.d/unreleased/3137.security.md b/changelog.d/unreleased/3137.security.md new file mode 100644 index 0000000000..b75c167985 --- /dev/null +++ b/changelog.d/unreleased/3137.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3137 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **LSP document path fallback now caps basename candidates (#3137)** — exact indexed path resolution still wins, while basename fallback stops after a small bounded candidate set. + +## 日本語 + +- **LSP document path fallback の basename candidate 数に上限を追加しました (#3137)** — exact indexed path resolution を優先しつつ、basename fallback は小さな bounded candidate set で打ち切ります。 diff --git a/changelog.d/unreleased/3203.security.md b/changelog.d/unreleased/3203.security.md new file mode 100644 index 0000000000..634c6fc436 --- /dev/null +++ b/changelog.d/unreleased/3203.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3203 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **LSP `textDocument.uri` now has explicit type validation (#3203)** — document handlers reject non-string URI values before URI parsing or path resolution while keeping the JSON-RPC error bounded. + +## 日本語 + +- **LSP `textDocument.uri` の型検証を明示しました (#3203)** — document handler は URI parse や path resolution の前に string 以外の URI 値を拒否し、JSON-RPC error を bounded に保ちます。 diff --git a/changelog.d/unreleased/3204.security.md b/changelog.d/unreleased/3204.security.md new file mode 100644 index 0000000000..2835827284 --- /dev/null +++ b/changelog.d/unreleased/3204.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3204 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **LSP request IDs are validated before cloning (#3204)** — request IDs are now limited to bounded JSON-RPC scalar values before the server copies them into responses. + +## 日本語 + +- **LSP request ID を複製前に検証するようになりました (#3204)** — request ID は response へコピーされる前に、bounded な JSON-RPC scalar value に制限されます。 diff --git a/changelog.d/unreleased/3205.security.md b/changelog.d/unreleased/3205.security.md new file mode 100644 index 0000000000..fb13c6c880 --- /dev/null +++ b/changelog.d/unreleased/3205.security.md @@ -0,0 +1,16 @@ +--- +category: security +issues: + - 3205 +affected: + - tests/CodeIndex.Tests/LspServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **LSP unknown-method diagnostics keep method names capped (#3205)** — coverage and documentation now explicitly lock the existing 240-character echo limit for method-not-found responses. + +## 日本語 + +- **LSP unknown-method diagnostic の method name echo 上限を明示しました (#3205)** — method-not-found response が既存の 240 文字上限を維持することを test と documentation で固定しました。 diff --git a/changelog.d/unreleased/3206.security.md b/changelog.d/unreleased/3206.security.md new file mode 100644 index 0000000000..3b2ed3cd34 --- /dev/null +++ b/changelog.d/unreleased/3206.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3206 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **LSP document operations reject non-file URIs (#3206)** — document handlers no longer treat `untitled:` or other non-`file:` URI schemes as workspace-relative paths. + +## 日本語 + +- **LSP document 操作が non-file URI を拒否するようになりました (#3206)** — document handler は `untitled:` など `file:` 以外の URI scheme を workspace-relative path として扱わなくなりました。 diff --git a/changelog.d/unreleased/3229.security.md b/changelog.d/unreleased/3229.security.md new file mode 100644 index 0000000000..0a873db3e1 --- /dev/null +++ b/changelog.d/unreleased/3229.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3229 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **LSP frame parsing rejects duplicate `Content-Length` headers (#3229)** — duplicate length headers are no longer accepted even when they repeat the same value. + +## 日本語 + +- **LSP frame parsing が重複した `Content-Length` header を拒否するようになりました (#3229)** — 同じ値の繰り返しであっても、length header の重複は受理されません。 diff --git a/changelog.d/unreleased/3230.security.md b/changelog.d/unreleased/3230.security.md new file mode 100644 index 0000000000..2133433a9c --- /dev/null +++ b/changelog.d/unreleased/3230.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3230 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **LSP frame headers now have count and aggregate byte limits (#3230)** — the LSP parser rejects excessive header lines or aggregate header bytes before reading the message body. + +## 日本語 + +- **LSP frame header に行数と合計 byte 数の上限を追加しました (#3230)** — LSP parser は message body を読む前に過大な header 行数または合計 header byte 数を拒否します。 diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index 5677275341..a8fd0155fe 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -15,10 +15,18 @@ internal sealed class LspServer : IDisposable private const int DefaultLimit = 50; internal const int MaxLspFrameBytes = 8 * 1024 * 1024; internal const int MaxLspHeaderLineBytes = 8 * 1024; + internal const int MaxLspHeaderCount = 64; + internal const int MaxLspHeaderBytes = 64 * 1024; internal const int MaxPositionDocumentBytes = 4 * 1024 * 1024; internal const int MaxTextDocumentUriChars = McpBoundedText.MaxResourceUriChars; internal const int MaxLspRequestIdRawBytes = 4 * 1024; internal const int MaxJsonDepth = 32; + internal const int MaxRequestIdStringChars = 256; + internal const int MaxDocumentSymbols = 1000; + internal const int MaxDocumentSymbolDetailChars = 512; + internal const int MaxDocumentSymbolResponseBytes = 512 * 1024; + internal const int MaxPositionLineChars = 16 * 1024; + internal const int MaxDocumentPathFallbackCandidates = 32; internal const int MaxUnknownMethodDiagnosticChars = 240; private const int JsonRpcInvalidParamsCode = -32602; private const int JsonRpcInternalErrorCode = -32603; @@ -92,8 +100,8 @@ public int Run(Stream input, Stream output) var method = root.TryGetProperty("method", out var methodElement) ? methodElement.GetString() : null; hasId = root.TryGetProperty("id", out var idElement); - if (hasId && !TryParseRequestId(payload, idElement, out id)) - return Error(null, -32600, $"Request id must be {MaxLspRequestIdRawBytes} raw JSON bytes or fewer."); + if (hasId && !TryParseRequestId(payload, idElement, out id, out var requestIdError)) + return Error(null, -32600, requestIdError); if (method == null) return hasId ? Error(id, -32600, "Invalid Request") : null; @@ -122,18 +130,50 @@ public int Run(Stream input, Stream output) } } - private static bool TryParseRequestId(string payload, JsonElement idElement, out JsonNode? id) + private static bool TryParseRequestId(string payload, JsonElement idElement, out JsonNode? id, out string errorMessage) { id = null; + errorMessage = "Invalid Request"; if (!TryGetTopLevelRequestIdRawByteCount(payload, out var rawIdBytes) || rawIdBytes > MaxLspRequestIdRawBytes) + { + errorMessage = $"Request id must be {MaxLspRequestIdRawBytes} raw JSON bytes or fewer."; return false; + } var rawId = idElement.GetRawText(); if (Encoding.UTF8.GetByteCount(rawId) > MaxLspRequestIdRawBytes) + { + errorMessage = $"Request id must be {MaxLspRequestIdRawBytes} raw JSON bytes or fewer."; return false; + } - id = JsonNode.Parse(rawId, documentOptions: LspJsonDocumentOptions); - return true; + return TryCloneRequestId(idElement, out id); + } + + private static bool TryCloneRequestId(JsonElement idElement, out JsonNode? id) + { + id = null; + switch (idElement.ValueKind) + { + case JsonValueKind.String: + var value = idElement.GetString(); + if (value == null || value.Length > MaxRequestIdStringChars) + return false; + id = JsonValue.Create(value); + return true; + + case JsonValueKind.Number: + if (!idElement.TryGetInt64(out var number)) + return false; + id = JsonValue.Create(number); + return true; + + case JsonValueKind.Null: + return true; + + default: + return false; + } } private static bool TryGetTopLevelRequestIdRawByteCount(string payload, out int rawIdBytes) @@ -232,6 +272,9 @@ private JsonObject HandleShutdown(JsonNode? id) private JsonArray WorkspaceSymbol(JsonElement root) { var query = GetString(root, "params", "query"); + if (query != null && query.Length > QueryLimits.MaxQueryLength) + throw new ArgumentException(QueryLimits.FormatQueryTooLongError()); + var symbols = _reader.SearchSymbols(query, DefaultLimit); var array = new JsonArray(); foreach (var symbol in symbols) @@ -246,10 +289,20 @@ private JsonArray DocumentSymbol(JsonElement root) if (indexedPath == null) return []; - var symbols = _reader.SearchSymbols((string?)null, 1000, pathPatterns: [indexedPath]); + var symbols = _reader.SearchSymbols((string?)null, MaxDocumentSymbols, pathPatterns: [indexedPath]); var array = new JsonArray(); + var responseBytes = 2; foreach (var symbol in symbols.OrderBy(s => s.StartLine).ThenBy(s => s.Name, StringComparer.Ordinal)) - array.Add(ToDocumentSymbol(symbol)); + { + var item = ToDocumentSymbol(symbol); + var itemBytes = Encoding.UTF8.GetByteCount(item.ToJsonString(_jsonOptions)); + var separatorBytes = array.Count == 0 ? 0 : 1; + if (responseBytes + separatorBytes + itemBytes > MaxDocumentSymbolResponseBytes) + break; + + responseBytes += separatorBytes + itemBytes; + array.Add(item); + } return array; } @@ -349,24 +402,56 @@ private static bool TryReadPositionLine(string path, int targetLine, out string return false; using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); - for (var currentLine = 0; currentLine <= targetLine; currentLine++) + var currentLine = 0; + var currentLineLength = 0; + StringBuilder? builder = targetLine == 0 ? new StringBuilder() : null; + while (true) { - var line = reader.ReadLine(); - if (line == null) + var next = reader.Read(); + if (next < 0) + { + if (currentLine == targetLine && currentLineLength <= MaxPositionLineChars && builder != null) + { + sourceLine = builder.ToString(); + return true; + } + return false; - if (currentLine == targetLine) + } + + var c = (char)next; + if (c == '\r' || c == '\n') + { + if (c == '\r' && reader.Peek() == '\n') + reader.Read(); + + if (currentLine == targetLine) + { + sourceLine = builder?.ToString() ?? string.Empty; + return true; + } + + currentLine++; + currentLineLength = 0; + builder = currentLine == targetLine ? new StringBuilder() : null; + continue; + } + + currentLineLength++; + if (currentLineLength > MaxPositionLineChars) { - sourceLine = line; - return true; + if (currentLine == targetLine) + return false; + continue; } + + builder?.Append(c); } } catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) { return false; } - - return false; } internal static string? ExtractTokenAtUtf16Position(string line, int character) @@ -440,7 +525,7 @@ private bool MatchesDocumentPath(string indexedPath, string documentPath, string if (string.IsNullOrEmpty(fileName)) return null; - var files = _reader.ListFiles(fileName, 1000); + var files = _reader.ListFiles(fileName, MaxDocumentPathFallbackCandidates); var matches = files .Where(file => MatchesDocumentPath(file.Path, documentPath, projectRelativePath)) .Take(2) @@ -526,9 +611,16 @@ private bool TryGetProjectRelativePath(string resolvedPath, out string? relative ["kind"] = SymbolKind(symbol.Kind), ["range"] = ToRange(symbol.StartLine, 1, symbol.EndLine, 1), ["selectionRange"] = ToRange(symbol.Line, 1, symbol.Line, 1), - ["detail"] = symbol.Signature, + ["detail"] = TruncateDocumentSymbolDetail(symbol.Signature), }; + private static string? TruncateDocumentSymbolDetail(string? detail) + { + if (detail == null || detail.Length <= MaxDocumentSymbolDetailChars) + return detail; + return detail[..(MaxDocumentSymbolDetailChars - "...".Length)] + "..."; + } + private JsonObject ToLocation(string path, int startLine, int startColumn, int endLine, int endColumn) => new() { ["uri"] = PathToUri(path, _projectRoot), @@ -563,13 +655,22 @@ private bool TryGetProjectRelativePath(string resolvedPath, out string? relative private static string GetDocumentPath(JsonElement root) { - var uri = GetString(root, "params", "textDocument", "uri"); + var uri = GetTextDocumentUri(root); + return UriToPath(uri); + } + + private static string GetTextDocumentUri(JsonElement root) + { + if (!TryGet(root, out var value, "params", "textDocument", "uri") || value.ValueKind != JsonValueKind.String) + throw new ArgumentException("textDocument.uri must be a string."); + + var uri = value.GetString(); if (string.IsNullOrWhiteSpace(uri)) throw new ArgumentException("textDocument.uri is required."); if (uri.Length > MaxTextDocumentUriChars) throw new ArgumentException( $"textDocument.uri is too long. Max length is {MaxTextDocumentUriChars} characters; actual length is {uri.Length}."); - return UriToPath(uri); + return uri; } private static string? GetString(JsonElement root, params string[] path) @@ -608,7 +709,7 @@ internal static string PathToUri(string path, string? projectRoot = null) internal static string UriToPath(string uri) { if (!Uri.TryCreate(uri, UriKind.Absolute, out var parsed) || !parsed.IsFile) - return uri; + throw new ArgumentException("textDocument.uri must be an absolute file URI."); return parsed.LocalPath; } @@ -634,6 +735,9 @@ internal static bool TryReadMessage(Stream input, out string payload) { payload = string.Empty; var contentLength = -1; + var hasContentLength = false; + var headerCount = 0; + var headerBytes = 0; while (true) { var line = ReadAsciiLine(input); @@ -641,6 +745,10 @@ internal static bool TryReadMessage(Stream input, out string payload) return false; if (line.Length == 0) break; + headerCount++; + headerBytes += line.Length; + if (headerCount > MaxLspHeaderCount || headerBytes > MaxLspHeaderBytes) + return false; var colon = line.IndexOf(':'); if (colon <= 0) continue; @@ -648,6 +756,8 @@ internal static bool TryReadMessage(Stream input, out string payload) var value = line[(colon + 1)..].Trim(); if (string.Equals(name, "Content-Length", StringComparison.OrdinalIgnoreCase)) { + if (hasContentLength) + return false; if (!int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) || parsed < 0 || parsed > MaxLspFrameBytes) @@ -655,6 +765,7 @@ internal static bool TryReadMessage(Stream input, out string payload) return false; } + hasContentLength = true; contentLength = parsed; } } diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 8706c17011..a202813a12 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text; using System.Text.Json; using CodeIndex.Cli; @@ -50,6 +51,30 @@ public void TryReadMessage_RejectsHeaderLineOverMaxLength() Assert.Equal(string.Empty, actual); } + [Fact] + public void TryReadMessage_RejectsHeaderCountOverMax_Issue3230() + { + var headers = Enumerable.Range(0, LspServer.MaxLspHeaderCount) + .Select(i => $"X-{i}: value"); + var bytes = Encoding.UTF8.GetBytes(string.Join("\r\n", headers) + "\r\nContent-Length: 2\r\n\r\n{}"); + using var stream = new MemoryStream(bytes); + + Assert.False(LspServer.TryReadMessage(stream, out var actual)); + Assert.Equal(string.Empty, actual); + } + + [Fact] + public void TryReadMessage_RejectsAggregateHeaderBytesOverMax_Issue3230() + { + var maxLineHeader = "X-" + new string('A', LspServer.MaxLspHeaderLineBytes - 2); + var headers = Enumerable.Repeat(maxLineHeader, (LspServer.MaxLspHeaderBytes / LspServer.MaxLspHeaderLineBytes) + 1); + var bytes = Encoding.UTF8.GetBytes(string.Join("\r\n", headers) + "\r\nContent-Length: 2\r\n\r\n{}"); + using var stream = new MemoryStream(bytes); + + Assert.False(LspServer.TryReadMessage(stream, out var actual)); + Assert.Equal(string.Empty, actual); + } + [Fact] public void TryReadMessage_RejectsFrameOverMaxLength() { @@ -60,6 +85,18 @@ public void TryReadMessage_RejectsFrameOverMaxLength() Assert.Equal(string.Empty, actual); } + [Theory] + [InlineData("2", "2")] + [InlineData("2", "3")] + public void TryReadMessage_RejectsDuplicateContentLength_Issue3229(string firstLength, string secondLength) + { + var bytes = Encoding.UTF8.GetBytes($"Content-Length: {firstLength}\r\nContent-Length: {secondLength}\r\n\r\n{{}}"); + using var stream = new MemoryStream(bytes); + + Assert.False(LspServer.TryReadMessage(stream, out var actual)); + Assert.Equal(string.Empty, actual); + } + [Fact] public void HandleMessage_Initialize_AdvertisesCoreCapabilities() { @@ -139,6 +176,40 @@ public void HandleMessage_UnknownMethod_TruncatesMethodName_Issue3127() } } + [Fact] + public void HandleMessage_UnknownMethod_TruncatesMethodName_Issue3205() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_unknown_method_3205"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var method = "workspace/" + new string('m', LspServer.MaxUnknownMethodDiagnosticChars + 20) + "LEAK_SENTINEL"; + var request = JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id = 3205, + method, + }); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + var error = response!["error"]!; + Assert.Equal(-32601, error["code"]!.GetValue()); + var message = error["message"]!.GetValue(); + Assert.StartsWith("Method not found: workspace/", message, StringComparison.Ordinal); + Assert.EndsWith("...", message, StringComparison.Ordinal); + Assert.DoesNotContain("LEAK_SENTINEL", message, StringComparison.Ordinal); + Assert.True(message.Length <= "Method not found: ".Length + LspServer.MaxUnknownMethodDiagnosticChars + "...".Length); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void HandleMessage_UnknownMethod_PreservesSlashDelimitedMethodName_Issue3127() { @@ -167,6 +238,58 @@ public void HandleMessage_UnknownMethod_PreservesSlashDelimitedMethodName_Issue3 } } + [Fact] + public void HandleMessage_ObjectRequestId_ReturnsInvalidRequest_Issue3204() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_object_id"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + + var response = server.HandleMessage("""{"jsonrpc":"2.0","id":{"nested":1},"method":"initialize"}"""); + + Assert.NotNull(response); + Assert.Equal(-32600, response!["error"]!["code"]!.GetValue()); + Assert.Null(response["id"]); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void HandleMessage_OversizedStringRequestId_ReturnsInvalidRequest_Issue3204() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_long_id"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var oversizedId = new string('i', LspServer.MaxRequestIdStringChars + 1); + var request = JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id = oversizedId, + method = "initialize", + }); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + Assert.Equal(-32600, response!["error"]!["code"]!.GetValue()); + Assert.Null(response["id"]); + Assert.DoesNotContain(oversizedId, response.ToJsonString(), StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void HandleMessage_InvalidParams_ReturnsStableErrorMessage_Issue3200() { @@ -236,6 +359,41 @@ public void HandleMessage_InternalFailure_ReturnsStableErrorMessage_Issue3200() } } + [Fact] + public void HandleMessage_WorkspaceSymbol_RejectsOversizedQuery_Issue3128() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_workspace_symbol_long_query"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var oversizedQuery = new string('q', QueryLimits.MaxQueryLength + 1); + var request = JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id = 3128, + method = "workspace/symbol", + @params = new + { + query = oversizedQuery, + }, + }); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + var error = response!["error"]!; + Assert.Equal(-32602, error["code"]!.GetValue()); + Assert.Equal("Invalid params", error["message"]!.GetValue()); + Assert.DoesNotContain(oversizedQuery, response.ToJsonString(), StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void Run_MalformedJsonFrame_WritesParseErrorAndContinues() { @@ -444,6 +602,132 @@ public void HandleMessage_DocumentSymbol_RejectsOversizedTextDocumentUri_Issue31 } } + [Fact] + public void HandleMessage_DocumentSymbol_TruncatesDetailsAndCapsResponse_Issue3130() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_document_symbol_budget"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sourcePath = Path.Combine(projectRoot, "large.cs"); + var parameters = string.Join(", ", Enumerable.Range(0, 90).Select(i => $"int argument{i:D2}")); + var source = new StringBuilder("class LargeSymbols\n{\n"); + for (var i = 0; i < LspServer.MaxDocumentSymbols; i++) + source.Append(" void Method").Append(i.ToString("D4", CultureInfo.InvariantCulture)).Append('(').Append(parameters).Append(") { }\n"); + source.Append("}\n"); + + File.WriteAllText(sourcePath, source.ToString()); + TestProjectHelper.InsertIndexedFile(dbPath, "large.cs", "csharp", source.ToString()); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var request = JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id = 3130, + method = "textDocument/documentSymbol", + @params = new + { + textDocument = new { uri = new Uri(sourcePath).AbsoluteUri }, + }, + }); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + var symbols = response!["result"]!.AsArray(); + Assert.NotEmpty(symbols); + Assert.True(symbols.Count < LspServer.MaxDocumentSymbols); + Assert.True(Encoding.UTF8.GetByteCount(symbols.ToJsonString()) <= LspServer.MaxDocumentSymbolResponseBytes); + Assert.Contains(symbols, symbol => + { + var detail = symbol?["detail"]?.GetValue(); + return detail is { Length: <= LspServer.MaxDocumentSymbolDetailChars } + && detail.EndsWith("...", StringComparison.Ordinal); + }); + Assert.All(symbols, symbol => + { + var detail = symbol?["detail"]?.GetValue(); + if (detail != null) + Assert.True(detail.Length <= LspServer.MaxDocumentSymbolDetailChars); + }); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void HandleMessage_DocumentSymbol_RejectsNonStringTextDocumentUri_Issue3203() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_document_symbol_uri_type"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var request = JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id = 3203, + method = "textDocument/documentSymbol", + @params = new + { + textDocument = new { uri = 123 }, + }, + }); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + var error = response!["error"]!; + Assert.Equal(-32602, error["code"]!.GetValue()); + var message = error["message"]!.GetValue(); + Assert.Equal("Invalid params", message); + Assert.DoesNotContain("123", response.ToJsonString(), StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Theory] + [InlineData("untitled:scratch.cs")] + [InlineData("https://example.invalid/app.cs")] + public void HandleMessage_DocumentSymbol_RejectsNonFileTextDocumentUri_Issue3206(string uri) + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_document_symbol_uri_scheme"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var request = JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id = 3206, + method = "textDocument/documentSymbol", + @params = new + { + textDocument = new { uri }, + }, + }); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + var error = response!["error"]!; + Assert.Equal(-32602, error["code"]!.GetValue()); + Assert.Equal("Invalid params", error["message"]!.GetValue()); + Assert.DoesNotContain(uri, response.ToJsonString(), StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void HandleMessage_Definition_ReturnsLocationForTokenAtPosition() { @@ -715,6 +999,37 @@ public void HandleMessage_Definition_ReturnsEmptyForOversizedIndexedDocument() } } + [Fact] + public void HandleMessage_Definition_ReturnsEmptyForLineOverPositionBudget_Issue3136() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_definition_long_line"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sourcePath = Path.Combine(projectRoot, "long_line.cs"); + var indexedSource = "class App { void Needle() { } void Call() { Needle(); } }\n"; + TestProjectHelper.InsertIndexedFile(dbPath, "long_line.cs", "csharp", indexedSource); + var oversizedLine = new string('x', LspServer.MaxPositionLineChars + 1) + " Needle();\n"; + File.WriteAllText(sourcePath, oversizedLine); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var request = CreateDefinitionRequest( + sourcePath, + 3136, + 0, + oversizedLine.IndexOf("Needle();", StringComparison.Ordinal)); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + Assert.Empty(response!["result"]!.AsArray()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void HandleMessage_Definition_HonorsCaseInsensitiveWorkspaceCasing() { @@ -822,6 +1137,47 @@ public void HandleMessage_Definition_ResolvesIndexedDocumentBeyondBasenameCandid } } + [Fact] + public void HandleMessage_Definition_BasenameFallbackHonorsCandidateCap_Issue3137() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_definition_bounded_basename"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + for (var i = 0; i < LspServer.MaxDocumentPathFallbackCandidates; i++) + { + var fillerPath = Path.Combine(projectRoot, "src", i.ToString("D4", CultureInfo.InvariantCulture), "index.cs"); + TestProjectHelper.InsertIndexedFile( + dbPath, + fillerPath, + "csharp", + $"class Filler{i} {{ void Needle() {{ }} }}\n"); + } + + var targetPath = Path.Combine(projectRoot, "src", "9999", "index.cs"); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + var source = "class Target { void Needle() { } void Call() { Needle(); } }\n"; + File.WriteAllText(targetPath, source); + TestProjectHelper.InsertIndexedFile(dbPath, targetPath, "csharp", source); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions()); + var request = CreateDefinitionRequest( + targetPath, + 3137, + 0, + source.IndexOf("Needle();", StringComparison.Ordinal)); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + Assert.Empty(response!["result"]!.AsArray()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + private static string CreateDefinitionRequest(string sourcePath, int id, int line, int character) => JsonSerializer.Serialize(new {