diff --git a/changelog.d/unreleased/2825.fixed.md b/changelog.d/unreleased/2825.fixed.md new file mode 100644 index 0000000000..fb47eea1b0 --- /dev/null +++ b/changelog.d/unreleased/2825.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 2825 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs +--- + +## English + +- **LSP message framing now rejects oversized frames and header lines (#2825)** — `cdidx lsp` now caps `Content-Length` frames and individual header lines before renting payload buffers. + +## 日本語 + +- **LSP message framing が過大な frame と header line を拒否するようになりました (#2825)** — `cdidx lsp` は payload buffer を確保する前に `Content-Length` frame と個別 header line の上限を適用します。 diff --git a/changelog.d/unreleased/2826.fixed.md b/changelog.d/unreleased/2826.fixed.md new file mode 100644 index 0000000000..7ed1ef9951 --- /dev/null +++ b/changelog.d/unreleased/2826.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 2826 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs +--- + +## English + +- **LSP position requests now stay inside indexed project files (#2826)** — `definition` and `references` now ignore unindexed, outside-root, or oversized documents and read only the requested line instead of materializing the whole file. + +## 日本語 + +- **LSP position request が indexed project file 内に制限されました (#2826)** — `definition` / `references` は未 index、project root 外、または過大な document を無視し、ファイル全体を materialize せず要求行だけを読み取ります。 diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 6a439d799f..74eb32dcde 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -1569,7 +1569,14 @@ private static int RunLsp(string[] cmdArgs, string appVersion, JsonSerializerOpt } db.TryMigrateForRead(); - using var server = new LspServer(new DbReader(db), appVersion, jsonOptions, db.GetMetaString(DbContext.IndexedProjectRootMetaKey)); + var indexedProjectRoot = db.GetMetaString(DbContext.IndexedProjectRootMetaKey); + if (!string.IsNullOrWhiteSpace(indexedProjectRoot) + && bool.TryParse(db.GetMetaString(DbContext.WorkspacePathCaseSensitiveMetaKey), out var pathCaseSensitive)) + { + PathCasing.SeedFromWorkspace(indexedProjectRoot, ignoreCase: !pathCaseSensitive); + } + + using var server = new LspServer(new DbReader(db), appVersion, jsonOptions, indexedProjectRoot); server.Run(Console.OpenStandardInput(), Console.OpenStandardOutput()); return CommandExitCodes.Success; } diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index c633e61afc..5a360ceb57 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -1,7 +1,9 @@ using System.Buffers; +using System.Globalization; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; +using CodeIndex.Cli; using CodeIndex.Database; using CodeIndex.Models; @@ -10,10 +12,14 @@ namespace CodeIndex.Lsp; 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 MaxPositionDocumentBytes = 4 * 1024 * 1024; private readonly DbReader _reader; private readonly string _version; private readonly JsonSerializerOptions _jsonOptions; private readonly string? _projectRoot; + private readonly StringComparison _pathStringComparison; private bool _shutdownRequested; public LspServer(DbReader reader, string version, JsonSerializerOptions jsonOptions, string? projectRoot = null) @@ -22,6 +28,7 @@ public LspServer(DbReader reader, string version, JsonSerializerOptions jsonOpti _version = version; _jsonOptions = jsonOptions; _projectRoot = string.IsNullOrWhiteSpace(projectRoot) ? null : projectRoot; + _pathStringComparison = PathCasing.ComparisonFor(_projectRoot ?? Environment.CurrentDirectory); } public void Run(Stream input, Stream output) @@ -147,15 +154,50 @@ private JsonArray References(JsonElement root) if (line < 0 || character < 0) return null; - var resolved = Path.IsPathRooted(path) ? path : Path.GetFullPath(path); - if (!File.Exists(resolved)) + if (!TryResolveDocumentPath(path, out var resolvedPath, out var projectRelativePath)) return null; - var lines = File.ReadAllLines(resolved); - if (line >= lines.Length) + var indexedPath = ResolveIndexedPath(path, resolvedPath, projectRelativePath); + if (indexedPath == null || !TryResolveIndexedFilePath(indexedPath, out var indexedFullPath)) return null; - return ExtractTokenAtUtf16Position(lines[line], character); + if (!string.Equals(resolvedPath, indexedFullPath, _pathStringComparison)) + return null; + + if (!TryReadPositionLine(indexedFullPath, line, out var sourceLine)) + return null; + + return ExtractTokenAtUtf16Position(sourceLine, character); + } + + private static bool TryReadPositionLine(string path, int targetLine, out string sourceLine) + { + sourceLine = string.Empty; + try + { + using var stream = File.OpenRead(path); + if (stream.Length > MaxPositionDocumentBytes) + return false; + + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + for (var currentLine = 0; currentLine <= targetLine; currentLine++) + { + var line = reader.ReadLine(); + if (line == null) + return false; + if (currentLine == targetLine) + { + sourceLine = line; + return true; + } + } + } + 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) @@ -181,27 +223,126 @@ private JsonArray References(JsonElement root) private static bool IsTokenChar(char c) => char.IsLetterOrDigit(c) || c == '_' || c == '@'; - private static bool MatchesDocumentPath(string indexedPath, string documentPath) + private bool MatchesDocumentPath(string indexedPath, string documentPath, string? projectRelativePath) { + var normalizedIndexed = indexedPath.Replace('\\', '/'); + if (_projectRoot != null) + { + if (Path.IsPathRooted(indexedPath) + && TryResolveIndexedFilePath(indexedPath, out var indexedFullPath) + && TryGetProjectRelativePath(indexedFullPath, out var indexedRelativePath) + && indexedRelativePath != null) + { + normalizedIndexed = indexedRelativePath.Replace('\\', '/'); + } + + return projectRelativePath != null + && string.Equals(normalizedIndexed, projectRelativePath.Replace('\\', '/'), _pathStringComparison); + } + if (string.Equals(indexedPath, documentPath, StringComparison.Ordinal)) return true; - var normalizedIndexed = indexedPath.Replace('\\', '/'); var normalizedDocument = documentPath.Replace('\\', '/'); return normalizedDocument.EndsWith("/" + normalizedIndexed, StringComparison.Ordinal); } private string? ResolveIndexedPath(string documentPath) { + if (!TryResolveDocumentPath(documentPath, out var resolvedPath, out var projectRelativePath)) + return null; + + return ResolveIndexedPath(documentPath, resolvedPath, projectRelativePath); + } + + private string? ResolveIndexedPath(string documentPath, string resolvedPath, string? projectRelativePath) + { + if (projectRelativePath != null) + { + var exactPath = projectRelativePath.Replace('\\', '/'); + var exactFile = _reader.GetFileByPath(exactPath); + if (exactFile != null) + return exactFile.Path; + } + var fileName = Path.GetFileName(documentPath); + if (string.IsNullOrEmpty(fileName)) + fileName = Path.GetFileName(resolvedPath); + if (string.IsNullOrEmpty(fileName)) + return null; + var files = _reader.ListFiles(fileName, 1000); var matches = files - .Where(file => MatchesDocumentPath(file.Path, documentPath)) + .Where(file => MatchesDocumentPath(file.Path, documentPath, projectRelativePath)) .Take(2) .ToList(); return matches.Count == 1 ? matches[0].Path : null; } + private bool TryResolveDocumentPath(string documentPath, out string resolvedPath, out string? projectRelativePath) + { + resolvedPath = string.Empty; + projectRelativePath = null; + try + { + resolvedPath = Path.IsPathRooted(documentPath) + ? Path.GetFullPath(documentPath) + : Path.GetFullPath(documentPath, _projectRoot ?? Environment.CurrentDirectory); + } + catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) + { + return false; + } + + if (_projectRoot == null) + return true; + + return TryGetProjectRelativePath(resolvedPath, out projectRelativePath); + } + + private bool TryResolveIndexedFilePath(string indexedPath, out string resolvedPath) + { + resolvedPath = string.Empty; + try + { + resolvedPath = Path.IsPathRooted(indexedPath) + ? Path.GetFullPath(indexedPath) + : Path.GetFullPath(indexedPath, _projectRoot ?? Environment.CurrentDirectory); + return true; + } + catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) + { + return false; + } + } + + private bool TryGetProjectRelativePath(string resolvedPath, out string? relativePath) + { + relativePath = null; + if (_projectRoot == null) + return false; + + try + { + var relative = Path.GetRelativePath(Path.GetFullPath(_projectRoot), resolvedPath); + if (relative == "." + || relative == ".." + || relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) + || relative.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal) + || Path.IsPathRooted(relative)) + { + return false; + } + + relativePath = relative; + return true; + } + catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) + { + return false; + } + } + private JsonObject ToWorkspaceSymbol(SymbolResult symbol) => new() { ["name"] = symbol.Name, @@ -333,9 +474,15 @@ internal static bool TryReadMessage(Stream input, out string payload) continue; var name = line[..colon].Trim(); var value = line[(colon + 1)..].Trim(); - if (string.Equals(name, "Content-Length", StringComparison.OrdinalIgnoreCase) - && int.TryParse(value, out var parsed)) + if (string.Equals(name, "Content-Length", StringComparison.OrdinalIgnoreCase)) { + if (!int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) + || parsed < 0 + || parsed > MaxLspFrameBytes) + { + return false; + } + contentLength = parsed; } } @@ -383,7 +530,11 @@ internal static void WriteMessage(Stream output, string payload) if (value == '\n') break; if (value != '\r') + { + if (bytes.Count >= MaxLspHeaderLineBytes) + return null; bytes.Add((byte)value); + } } return Encoding.ASCII.GetString(bytes.ToArray()); } diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index a547fc694b..34149cdd0d 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -27,6 +27,39 @@ public void TryReadMessage_ReadsContentLengthFramedPayload() Assert.Equal(payload, actual); } + [Fact] + public void TryReadMessage_AcceptsHeaderLineAtMaxLength() + { + const string payload = "{}"; + var maxLengthHeader = "X-" + new string('A', LspServer.MaxLspHeaderLineBytes - 2); + var bytes = Encoding.UTF8.GetBytes($"{maxLengthHeader}\r\nContent-Length: {payload.Length}\r\n\r\n{payload}"); + using var stream = new MemoryStream(bytes); + + Assert.True(LspServer.TryReadMessage(stream, out var actual)); + Assert.Equal(payload, actual); + } + + [Fact] + public void TryReadMessage_RejectsHeaderLineOverMaxLength() + { + var oversizedHeader = "X-" + new string('A', LspServer.MaxLspHeaderLineBytes - 1); + var bytes = Encoding.UTF8.GetBytes($"{oversizedHeader}\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() + { + var bytes = Encoding.UTF8.GetBytes($"Content-Length: {LspServer.MaxLspFrameBytes + 1}\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() { @@ -167,4 +200,223 @@ public void HandleMessage_Definition_ReturnsLocationForTokenAtPosition() TestProjectHelper.DeleteDirectory(projectRoot); } } + + [Fact] + public void HandleMessage_Definition_ReturnsEmptyForUnindexedDocument() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_definition_unindexed"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var indexedPath = Path.Combine(projectRoot, "indexed.cs"); + var indexedSource = "class Indexed { void Needle() { } }\n"; + File.WriteAllText(indexedPath, indexedSource); + TestProjectHelper.InsertIndexedFile(dbPath, "indexed.cs", "csharp", indexedSource); + var unindexedPath = Path.Combine(projectRoot, "unindexed.cs"); + var unindexedSource = "class Unindexed { void Call() { Needle(); } }\n"; + File.WriteAllText(unindexedPath, unindexedSource); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var request = CreateDefinitionRequest( + unindexedPath, + 4, + 0, + unindexedSource.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_ReturnsEmptyForOutsideProjectDocument() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_definition_project_root"); + var outsideRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_definition_outside"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var indexedPath = Path.Combine(projectRoot, "app.cs"); + var indexedSource = "class Indexed { void Needle() { } }\n"; + File.WriteAllText(indexedPath, indexedSource); + TestProjectHelper.InsertIndexedFile(dbPath, "app.cs", "csharp", indexedSource); + var outsidePath = Path.Combine(outsideRoot, "app.cs"); + var outsideSource = "class Outside { void Call() { Needle(); } }\n"; + File.WriteAllText(outsidePath, outsideSource); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var request = CreateDefinitionRequest( + outsidePath, + 5, + 0, + outsideSource.IndexOf("Needle();", StringComparison.Ordinal)); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + Assert.Empty(response!["result"]!.AsArray()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + TestProjectHelper.DeleteDirectory(outsideRoot); + } + } + + [Fact] + public void HandleMessage_Definition_ReturnsEmptyForOversizedIndexedDocument() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_definition_oversized"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sourcePath = Path.Combine(projectRoot, "huge.cs"); + var indexedSource = "class App { void Needle() { } }\n"; + TestProjectHelper.InsertIndexedFile(dbPath, "huge.cs", "csharp", indexedSource); + var oversizedSource = "class App { void Call() { Needle(); } }\n" + new string('x', LspServer.MaxPositionDocumentBytes); + File.WriteAllText(sourcePath, oversizedSource); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var request = CreateDefinitionRequest( + sourcePath, + 6, + 0, + oversizedSource.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() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_definition_case_insensitive"); + try + { + PathCasing.SeedFromWorkspace(projectRoot, ignoreCase: true); + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sourcePath = Path.Combine(projectRoot, "src", "Foo.cs"); + var requestPath = Path.Combine(projectRoot, "src", "foo.cs"); + Directory.CreateDirectory(Path.GetDirectoryName(sourcePath)!); + var source = "class App { void Needle() { } void Call() { Needle(); } }\n"; + File.WriteAllText(sourcePath, source); + TestProjectHelper.InsertIndexedFile(dbPath, "src/Foo.cs", "csharp", source); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var request = CreateDefinitionRequest( + requestPath, + 8, + 0, + source.IndexOf("Needle();", StringComparison.Ordinal)); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + Assert.NotEmpty(response!["result"]!.AsArray()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void HandleMessage_Definition_RejectsCaseVariantWhenWorkspaceCaseSensitive() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_definition_case_sensitive"); + try + { + PathCasing.SeedFromWorkspace(projectRoot, ignoreCase: false); + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sourcePath = Path.Combine(projectRoot, "src", "Foo.cs"); + var requestPath = Path.Combine(projectRoot, "src", "foo.cs"); + Directory.CreateDirectory(Path.GetDirectoryName(sourcePath)!); + var source = "class App { void Needle() { } void Call() { Needle(); } }\n"; + File.WriteAllText(sourcePath, source); + TestProjectHelper.InsertIndexedFile(dbPath, "src/Foo.cs", "csharp", source); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var request = CreateDefinitionRequest( + requestPath, + 9, + 0, + source.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_ResolvesIndexedDocumentBeyondBasenameCandidateCap() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_definition_many_basenames"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + for (var i = 0; i < 1001; i++) + { + TestProjectHelper.InsertIndexedFile( + dbPath, + $"src/{i:D4}/index.cs", + "csharp", + $"class Filler{i} {{ }}\n"); + } + + var targetRelativePath = "src/zzzz/index.cs"; + var sourcePath = Path.Combine(projectRoot, "src", "zzzz", "index.cs"); + Directory.CreateDirectory(Path.GetDirectoryName(sourcePath)!); + var source = "class Target { void Needle() { } void Call() { Needle(); } }\n"; + File.WriteAllText(sourcePath, source); + TestProjectHelper.InsertIndexedFile(dbPath, targetRelativePath, "csharp", source); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var request = CreateDefinitionRequest( + sourcePath, + 7, + 0, + source.IndexOf("Needle();", StringComparison.Ordinal)); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + Assert.NotEmpty(response!["result"]!.AsArray()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + private static string CreateDefinitionRequest(string sourcePath, int id, int line, int character) => + JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id, + method = "textDocument/definition", + @params = new + { + textDocument = new { uri = new Uri(sourcePath).AbsoluteUri }, + position = new { line, character }, + }, + }); }