f79c64cd44
Security fixes: - Path traversal guard in include: local: — paths with ../ that escape the repo root are rejected instead of reading arbitrary host files - HTTP timeout (30 s) on all fetcher requests to prevent indefinite hangs - Response size cap (10 MiB) via io.LimitReader to prevent memory exhaustion - Cache directory and file permissions tightened to 0700/0600 - LSP Content-Length cap (64 MiB) to guard against DoS from a malicious client New feature: - --proxy flag on check, graph, and lsp subcommands; also proxy: key in .glint.yml; overrides system HTTP_PROXY / HTTPS_PROXY env vars when set; cmdGraph and cmdLSP now also load .glint.yml for proxy/token/url fallbacks New lint rule: - GL045 (Warning): include: remote: using plain http:// instead of https:// Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
398 lines
11 KiB
Go
398 lines
11 KiB
Go
package lsp
|
||
|
||
import (
|
||
"bufio"
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"strconv"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.k3nny.fr/glint/internal/fetcher"
|
||
)
|
||
|
||
// frame encodes v as a Content-Length–framed LSP message.
|
||
func frame(t *testing.T, v any) []byte {
|
||
t.Helper()
|
||
body, err := json.Marshal(v)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
hdr := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(body))
|
||
return append([]byte(hdr), body...)
|
||
}
|
||
|
||
// readMsg reads one Content-Length–framed JSON object from r.
|
||
func readMsg(t *testing.T, r *bufio.Reader) map[string]json.RawMessage {
|
||
t.Helper()
|
||
var contentLength int
|
||
for {
|
||
line, err := r.ReadString('\n')
|
||
if err != nil {
|
||
t.Fatalf("reading header: %v", err)
|
||
}
|
||
line = strings.TrimRight(line, "\r\n")
|
||
if line == "" {
|
||
break
|
||
}
|
||
if strings.HasPrefix(line, "Content-Length: ") {
|
||
n, err := strconv.Atoi(strings.TrimPrefix(line, "Content-Length: "))
|
||
if err != nil {
|
||
t.Fatalf("invalid Content-Length: %v", err)
|
||
}
|
||
contentLength = n
|
||
}
|
||
}
|
||
body := make([]byte, contentLength)
|
||
if _, err := io.ReadFull(r, body); err != nil {
|
||
t.Fatalf("reading body: %v", err)
|
||
}
|
||
var m map[string]json.RawMessage
|
||
if err := json.Unmarshal(body, &m); err != nil {
|
||
t.Fatalf("unmarshal: %v", err)
|
||
}
|
||
return m
|
||
}
|
||
|
||
// newTestServer returns a Server with a captured exit code and a bufio.Reader
|
||
// wrapping the output buffer so tests can read back server messages.
|
||
func newTestServer(input []byte) (*Server, *bytes.Buffer, *int) {
|
||
var out bytes.Buffer
|
||
exitCode := -1
|
||
srv := New(bytes.NewReader(input), &out, fetcher.GitLabConfig{}, "test")
|
||
srv.Exit = func(code int) { exitCode = code }
|
||
return srv, &out, &exitCode
|
||
}
|
||
|
||
func TestServer_Initialize(t *testing.T) {
|
||
input := frame(t, map[string]any{
|
||
"jsonrpc": "2.0",
|
||
"id": 1,
|
||
"method": "initialize",
|
||
"params": map[string]any{},
|
||
})
|
||
srv, out, _ := newTestServer(input)
|
||
srv.Run() //nolint:errcheck
|
||
|
||
resp := readMsg(t, bufio.NewReader(out))
|
||
if string(resp["id"]) != "1" {
|
||
t.Errorf("response id = %s; want 1", resp["id"])
|
||
}
|
||
|
||
var result InitializeResult
|
||
if err := json.Unmarshal(resp["result"], &result); err != nil {
|
||
t.Fatalf("unmarshal result: %v", err)
|
||
}
|
||
if result.Capabilities.TextDocumentSync != 1 {
|
||
t.Errorf("textDocumentSync = %d; want 1", result.Capabilities.TextDocumentSync)
|
||
}
|
||
if result.ServerInfo == nil || result.ServerInfo.Name != "glint" {
|
||
t.Errorf("serverInfo.name = %v; want glint", result.ServerInfo)
|
||
}
|
||
}
|
||
|
||
func TestServer_ShutdownExit(t *testing.T) {
|
||
var buf bytes.Buffer
|
||
buf.Write(frame(t, map[string]any{
|
||
"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": map[string]any{},
|
||
}))
|
||
buf.Write(frame(t, map[string]any{
|
||
"jsonrpc": "2.0", "method": "initialized", "params": map[string]any{},
|
||
}))
|
||
buf.Write(frame(t, map[string]any{
|
||
"jsonrpc": "2.0", "id": 2, "method": "shutdown",
|
||
}))
|
||
buf.Write(frame(t, map[string]any{
|
||
"jsonrpc": "2.0", "method": "exit",
|
||
}))
|
||
|
||
srv, out, exitCode := newTestServer(buf.Bytes())
|
||
srv.Run() //nolint:errcheck
|
||
|
||
r := bufio.NewReader(out)
|
||
initResp := readMsg(t, r)
|
||
if string(initResp["id"]) != "1" {
|
||
t.Errorf("init response id = %s; want 1", initResp["id"])
|
||
}
|
||
shutResp := readMsg(t, r)
|
||
if string(shutResp["id"]) != "2" {
|
||
t.Errorf("shutdown response id = %s; want 2", shutResp["id"])
|
||
}
|
||
if string(shutResp["result"]) != "null" {
|
||
t.Errorf("shutdown result = %s; want null", shutResp["result"])
|
||
}
|
||
if *exitCode != 0 {
|
||
t.Errorf("exit code = %d; want 0", *exitCode)
|
||
}
|
||
}
|
||
|
||
func TestServer_ExitWithoutShutdown(t *testing.T) {
|
||
input := frame(t, map[string]any{
|
||
"jsonrpc": "2.0", "method": "exit",
|
||
})
|
||
srv, _, exitCode := newTestServer(input)
|
||
srv.Run() //nolint:errcheck
|
||
|
||
if *exitCode != 1 {
|
||
t.Errorf("exit code = %d; want 1 (no prior shutdown)", *exitCode)
|
||
}
|
||
}
|
||
|
||
func TestServer_MethodNotFound(t *testing.T) {
|
||
input := frame(t, map[string]any{
|
||
"jsonrpc": "2.0", "id": 99, "method": "workspace/unknownMethod",
|
||
})
|
||
srv, out, _ := newTestServer(input)
|
||
srv.Run() //nolint:errcheck
|
||
|
||
resp := readMsg(t, bufio.NewReader(out))
|
||
if resp["error"] == nil {
|
||
t.Errorf("expected error response for unknown method, got: %v", resp)
|
||
}
|
||
var rpcErr RPCError
|
||
if err := json.Unmarshal(resp["error"], &rpcErr); err != nil {
|
||
t.Fatalf("unmarshal error: %v", err)
|
||
}
|
||
if rpcErr.Code != -32601 {
|
||
t.Errorf("error code = %d; want -32601", rpcErr.Code)
|
||
}
|
||
}
|
||
|
||
func TestServer_UnknownNotificationIgnored(t *testing.T) {
|
||
// Notifications (no id) for unknown methods must be silently ignored.
|
||
input := frame(t, map[string]any{
|
||
"jsonrpc": "2.0", "method": "$/setTrace", "params": map[string]any{"value": "off"},
|
||
})
|
||
srv, out, _ := newTestServer(input)
|
||
srv.Run() //nolint:errcheck
|
||
|
||
if out.Len() > 0 {
|
||
t.Errorf("server wrote %d bytes for unknown notification; want 0", out.Len())
|
||
}
|
||
}
|
||
|
||
func TestServer_DidOpen_CleanPipeline(t *testing.T) {
|
||
yaml := `stages: [build]
|
||
|
||
build-job:
|
||
stage: build
|
||
script: echo hello
|
||
`
|
||
// Use a pseudo file:// URI that maps to the tmp path; include resolution
|
||
// will fail silently (no network, no local includes) which is fine.
|
||
uri := "file:///tmp/test.gitlab-ci.yml"
|
||
|
||
input := frame(t, map[string]any{
|
||
"jsonrpc": "2.0",
|
||
"method": "textDocument/didOpen",
|
||
"params": map[string]any{
|
||
"textDocument": map[string]any{
|
||
"uri": uri, "languageId": "yaml", "version": 1, "text": yaml,
|
||
},
|
||
},
|
||
})
|
||
srv, out, _ := newTestServer(input)
|
||
srv.Run() //nolint:errcheck
|
||
|
||
notif := readMsg(t, bufio.NewReader(out))
|
||
if string(notif["method"]) != `"textDocument/publishDiagnostics"` {
|
||
t.Fatalf("method = %s; want textDocument/publishDiagnostics", notif["method"])
|
||
}
|
||
var params PublishDiagnosticsParams
|
||
if err := json.Unmarshal(notif["params"], ¶ms); err != nil {
|
||
t.Fatalf("unmarshal params: %v", err)
|
||
}
|
||
if params.URI != uri {
|
||
t.Errorf("uri = %q; want %q", params.URI, uri)
|
||
}
|
||
// A clean pipeline should produce no diagnostics (or only warnings from
|
||
// include resolution being skipped — but those are filtered since they
|
||
// originate from a different file path).
|
||
for _, d := range params.Diagnostics {
|
||
if d.Severity == 1 {
|
||
t.Errorf("unexpected error diagnostic: %s", d.Message)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestServer_DidOpen_WithErrors(t *testing.T) {
|
||
// A pipeline with a job in an undeclared stage triggers GL004.
|
||
yaml := `stages: [build]
|
||
|
||
bad-job:
|
||
stage: missing-stage
|
||
script: echo hi
|
||
`
|
||
uri := "file:///tmp/bad.gitlab-ci.yml"
|
||
input := frame(t, map[string]any{
|
||
"jsonrpc": "2.0",
|
||
"method": "textDocument/didOpen",
|
||
"params": map[string]any{
|
||
"textDocument": map[string]any{
|
||
"uri": uri, "languageId": "yaml", "version": 1, "text": yaml,
|
||
},
|
||
},
|
||
})
|
||
srv, out, _ := newTestServer(input)
|
||
srv.Run() //nolint:errcheck
|
||
|
||
notif := readMsg(t, bufio.NewReader(out))
|
||
var params PublishDiagnosticsParams
|
||
if err := json.Unmarshal(notif["params"], ¶ms); err != nil {
|
||
t.Fatalf("unmarshal params: %v", err)
|
||
}
|
||
if len(params.Diagnostics) == 0 {
|
||
t.Error("expected diagnostics for pipeline with unknown stage, got none")
|
||
}
|
||
found := false
|
||
for _, d := range params.Diagnostics {
|
||
if d.Code == "GL004" {
|
||
found = true
|
||
if d.Severity != 1 {
|
||
t.Errorf("GL004 severity = %d; want 1 (Error)", d.Severity)
|
||
}
|
||
}
|
||
}
|
||
if !found {
|
||
t.Errorf("expected GL004 diagnostic, got: %v", params.Diagnostics)
|
||
}
|
||
}
|
||
|
||
func TestServer_DidOpen_ParseError(t *testing.T) {
|
||
uri := "file:///tmp/broken.gitlab-ci.yml"
|
||
input := frame(t, map[string]any{
|
||
"jsonrpc": "2.0",
|
||
"method": "textDocument/didOpen",
|
||
"params": map[string]any{
|
||
"textDocument": map[string]any{
|
||
"uri": uri, "languageId": "yaml", "version": 1,
|
||
"text": "?", // bare ? yields empty job name → parse error
|
||
},
|
||
},
|
||
})
|
||
srv, out, _ := newTestServer(input)
|
||
srv.Run() //nolint:errcheck
|
||
|
||
notif := readMsg(t, bufio.NewReader(out))
|
||
var params PublishDiagnosticsParams
|
||
if err := json.Unmarshal(notif["params"], ¶ms); err != nil {
|
||
t.Fatalf("unmarshal params: %v", err)
|
||
}
|
||
if len(params.Diagnostics) == 0 {
|
||
t.Fatal("expected parse-error diagnostic, got none")
|
||
}
|
||
d := params.Diagnostics[0]
|
||
if d.Severity != 1 {
|
||
t.Errorf("severity = %d; want 1 (Error)", d.Severity)
|
||
}
|
||
if !strings.Contains(d.Message, "YAML parse error") {
|
||
t.Errorf("message = %q; want YAML parse error", d.Message)
|
||
}
|
||
}
|
||
|
||
func TestServer_DidChange(t *testing.T) {
|
||
uri := "file:///tmp/ci.gitlab-ci.yml"
|
||
var buf bytes.Buffer
|
||
// Open with clean content.
|
||
buf.Write(frame(t, map[string]any{
|
||
"jsonrpc": "2.0", "method": "textDocument/didOpen",
|
||
"params": map[string]any{"textDocument": map[string]any{
|
||
"uri": uri, "languageId": "yaml", "version": 1,
|
||
"text": "stages: [build]\nbuild: {stage: build, script: echo}\n",
|
||
}},
|
||
}))
|
||
// Change to content with an error.
|
||
buf.Write(frame(t, map[string]any{
|
||
"jsonrpc": "2.0", "method": "textDocument/didChange",
|
||
"params": map[string]any{
|
||
"textDocument": map[string]any{"uri": uri, "version": 2},
|
||
"contentChanges": []map[string]any{
|
||
{"text": "stages: [build]\nbad: {stage: gone, script: hi}\n"},
|
||
},
|
||
},
|
||
}))
|
||
|
||
srv, out, _ := newTestServer(buf.Bytes())
|
||
srv.Run() //nolint:errcheck
|
||
|
||
r := bufio.NewReader(out)
|
||
_ = readMsg(t, r) // first publishDiagnostics (clean)
|
||
second := readMsg(t, r)
|
||
|
||
var params PublishDiagnosticsParams
|
||
if err := json.Unmarshal(second["params"], ¶ms); err != nil {
|
||
t.Fatalf("unmarshal: %v", err)
|
||
}
|
||
if len(params.Diagnostics) == 0 {
|
||
t.Error("expected diagnostics after change to broken content, got none")
|
||
}
|
||
}
|
||
|
||
func TestServer_DidClose_ClearsdiAgnostics(t *testing.T) {
|
||
uri := "file:///tmp/toclose.gitlab-ci.yml"
|
||
var buf bytes.Buffer
|
||
buf.Write(frame(t, map[string]any{
|
||
"jsonrpc": "2.0", "method": "textDocument/didOpen",
|
||
"params": map[string]any{"textDocument": map[string]any{
|
||
"uri": uri, "languageId": "yaml", "version": 1,
|
||
"text": "stages: [build]\nj: {stage: build, script: echo}\n",
|
||
}},
|
||
}))
|
||
buf.Write(frame(t, map[string]any{
|
||
"jsonrpc": "2.0", "method": "textDocument/didClose",
|
||
"params": map[string]any{"textDocument": map[string]any{"uri": uri}},
|
||
}))
|
||
|
||
srv, out, _ := newTestServer(buf.Bytes())
|
||
srv.Run() //nolint:errcheck
|
||
|
||
r := bufio.NewReader(out)
|
||
_ = readMsg(t, r) // publishDiagnostics from didOpen
|
||
|
||
closeNotif := readMsg(t, r)
|
||
var params PublishDiagnosticsParams
|
||
if err := json.Unmarshal(closeNotif["params"], ¶ms); err != nil {
|
||
t.Fatalf("unmarshal: %v", err)
|
||
}
|
||
if params.URI != uri {
|
||
t.Errorf("uri = %q; want %q", params.URI, uri)
|
||
}
|
||
if len(params.Diagnostics) != 0 {
|
||
t.Errorf("expected empty diagnostics on close, got %v", params.Diagnostics)
|
||
}
|
||
}
|
||
|
||
func TestServer_ContentLengthTooLarge(t *testing.T) {
|
||
// Craft a header with a content-length that exceeds the cap.
|
||
// The server must reject it before allocating a giant buffer.
|
||
header := fmt.Sprintf("Content-Length: %d\r\n\r\n", maxLSPMessageBytes+1)
|
||
srv, _, _ := newTestServer([]byte(header))
|
||
err := srv.Run()
|
||
if err == nil {
|
||
t.Fatal("expected error for oversized Content-Length, got nil")
|
||
}
|
||
if !strings.Contains(err.Error(), "exceeds maximum") {
|
||
t.Errorf("unexpected error: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestServer_UriToPath(t *testing.T) {
|
||
tests := []struct {
|
||
uri string
|
||
want string
|
||
}{
|
||
{"file:///tmp/ci.yml", "/tmp/ci.yml"},
|
||
{"file:///home/user/project/.gitlab-ci.yml", "/home/user/project/.gitlab-ci.yml"},
|
||
{"https://example.com/file.yml", ""},
|
||
{"not-a-uri", ""},
|
||
}
|
||
for _, tc := range tests {
|
||
got := uriToPath(tc.uri)
|
||
if got != tc.want {
|
||
t.Errorf("uriToPath(%q) = %q; want %q", tc.uri, got, tc.want)
|
||
}
|
||
}
|
||
}
|