дȫÏîĿÉó²éÎÊÌⲢ·¢²¼ v1.1.9
¸ÄÓñ¾»ú¶ÀÁ¢ CA£¬»ָ´ CLI TLS УÑ飬±£»¤ƾ¾ÝÓëÅäÖò¢·¢±£´档 д Cursor Êý¾ݡ¢»Ự·¾¶¡¢´úÀíÉúÃüÖÜÆڡ¢ģÐÍÇëÇóÓë Skills Îļþ²Ù×÷¡£ Ϊ¶ÀÁ¢ Tab ·þÎñÔö¼ӿͻ§¶˼øȨ£¬Éý¼¶ÒÀÀµÓ빤¾ßÁ´£¬ÍêÉƷ¢²¼ԪÐÅϢͬ²½¡£ Ñé֤£ºÁ½¸ö Go ģ¿éµIJâÊÔ/vet¡¢¹ؼüģ¿é race¡¢ǰ¶˲âÊÔ/Éú²ú¹¹½¨/Éó¼ơ¢ Go ¿ɴï©¶´ɨÃèÓë macOS/Linux ¿ÉÐÐģ¿齻²æ±àÒë¾ùͨ¹ý¡£ Windows ¸ôÀëÆô¶¯ͨ¹ý£»δÍê³ɵĵã»÷ðÑ̺ÍԭÉúƽ̨Ñé֤¼ûÉó²鱨¸档
This commit is contained in:
37
internal/cursor/cert_migration.go
Normal file
37
internal/cursor/cert_migration.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package cursor
|
||||
|
||||
import (
|
||||
"cursor/internal/certs"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// migrateCATrust verifies both sides of the migration. Removing the legacy
|
||||
// trust is necessary because its private key has already been public.
|
||||
func migrateCATrust(cert []byte, trusted func([]byte) (bool, error), migrate func() error) error {
|
||||
current, err := trusted(cert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
legacy, err := trusted(certs.EmbeddedCACertPEM())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current && !legacy {
|
||||
return nil
|
||||
}
|
||||
if err := migrate(); err != nil {
|
||||
return err
|
||||
}
|
||||
current, err = trusted(cert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
legacy, err = trusted(certs.EmbeddedCACertPEM())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !current || legacy {
|
||||
return fmt.Errorf("证书迁移未完成:需要信任本机 CA 并移除旧共享 CA;请重试授权")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -4,12 +4,15 @@
|
||||
package cursor
|
||||
|
||||
import (
|
||||
"cursor/internal/securefile"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"cursor/internal/appdata"
|
||||
@@ -68,88 +71,10 @@ func DetectCLIInstallPath() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// cliPreloadFilename 是 preload 脚本的文件名。
|
||||
const cliPreloadFilename = "tls-bypass.cjs"
|
||||
const cliPreloadFilename = "tls-redirect.cjs"
|
||||
|
||||
// cliPreloadScript 是注入到 Node.js 的 preload 脚本。
|
||||
//
|
||||
// 工作原理:
|
||||
// 1. patch net.connect — 将 cursor.sh 域名的 TCP 连接重定向到本地后端 HTTPS 端口
|
||||
// 2. patch tls.connect — 强制 rejectUnauthorized=false,使后端签发的 CA 证书被接受
|
||||
//
|
||||
// CLI 的 connect-node 使用 HTTP/2 (http2.connect),goproxy 无法 MITM HTTP/2 帧。
|
||||
// 因此跳过 MITM 代理,直接在 TCP 层重定向到后端的 HTTPS+HTTP/2 监听器。
|
||||
// 后端 Go http.Server.ServeTLS 原生支持 HTTP/2 ALPN。
|
||||
const cliPreloadScript = `// Generated by Local-Curosr: redirect cursor.sh TCP to local HTTPS backend
|
||||
'use strict';
|
||||
const tls = require('tls');
|
||||
const net = require('net');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
const logPath = path.join(os.homedir(), '.cursor-local-assistant-v2', 'logs', 'tls-bypass.log');
|
||||
function dbg(msg) {
|
||||
try { fs.appendFileSync(logPath, new Date().toISOString() + ' ' + msg + '\n'); } catch(e) {}
|
||||
}
|
||||
|
||||
var REDIRECT_PORT = parseInt(process.env.CLI_HTTPS_PORT || '39092', 10);
|
||||
var REDIRECT_HOST = '127.0.0.1';
|
||||
var REDIRECT_DOMAINS = ['api2.cursor.sh', 'api3.cursor.sh'];
|
||||
|
||||
function shouldRedirect(host) {
|
||||
if (!host) return false;
|
||||
host = String(host).toLowerCase().trim();
|
||||
for (var i = 0; i < REDIRECT_DOMAINS.length; i++) {
|
||||
var d = REDIRECT_DOMAINS[i];
|
||||
if (host === d || host.endsWith('.' + d)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- patch net.connect: TCP 层重定向 ---
|
||||
var origNetConnect = net.connect;
|
||||
function patchedNetConnect() {
|
||||
if (typeof arguments[0] === 'object' && arguments[0] !== null) {
|
||||
var opts = arguments[0];
|
||||
if (shouldRedirect(opts.host)) {
|
||||
dbg('net.connect redirect: ' + opts.host + ':' + opts.port + ' -> ' + REDIRECT_HOST + ':' + REDIRECT_PORT);
|
||||
opts.host = REDIRECT_HOST;
|
||||
opts.port = REDIRECT_PORT;
|
||||
}
|
||||
} else if (typeof arguments[0] === 'number' && typeof arguments[1] === 'string') {
|
||||
if (shouldRedirect(arguments[1])) {
|
||||
dbg('net.connect redirect (positional): ' + arguments[1] + ':' + arguments[0] + ' -> ' + REDIRECT_HOST + ':' + REDIRECT_PORT);
|
||||
arguments[1] = REDIRECT_HOST;
|
||||
arguments[0] = REDIRECT_PORT;
|
||||
}
|
||||
}
|
||||
return origNetConnect.apply(this, arguments);
|
||||
}
|
||||
net.connect = patchedNetConnect;
|
||||
net.createConnection = patchedNetConnect;
|
||||
|
||||
// --- patch tls.connect: 强制 rejectUnauthorized=false + 重定向 ---
|
||||
var origTLSConnect = tls.connect;
|
||||
tls.connect = function() {
|
||||
for (var i = 0; i < arguments.length && i < 3; i++) {
|
||||
if (arguments[i] && typeof arguments[i] === 'object' && !Array.isArray(arguments[i])) {
|
||||
var o = arguments[i];
|
||||
o.rejectUnauthorized = false;
|
||||
if (o.host && shouldRedirect(o.host)) {
|
||||
dbg('tls.connect redirect: ' + o.host + ':' + o.port + ' -> ' + REDIRECT_HOST + ':' + REDIRECT_PORT);
|
||||
o.host = REDIRECT_HOST;
|
||||
o.port = REDIRECT_PORT;
|
||||
if (!o.servername) o.servername = o.host;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return origTLSConnect.apply(this, arguments);
|
||||
};
|
||||
|
||||
dbg('tls-bypass preload loaded, redirect=' + REDIRECT_HOST + ':' + REDIRECT_PORT);
|
||||
`
|
||||
//go:embed cli_preload.cjs
|
||||
var cliPreloadScript string
|
||||
|
||||
// writeCLIPreloadScript 将 preload 脚本写入 wrapper 目录,返回文件路径。
|
||||
func writeCLIPreloadScript() (string, error) {
|
||||
@@ -158,7 +83,7 @@ func writeCLIPreloadScript() (string, error) {
|
||||
return "", fmt.Errorf("创建 preload 目录失败: %w", err)
|
||||
}
|
||||
preloadPath := filepath.Join(wrapperDir, cliPreloadFilename)
|
||||
if err := os.WriteFile(preloadPath, []byte(cliPreloadScript), 0o644); err != nil {
|
||||
if err := securefile.Write(preloadPath, []byte(cliPreloadScript)); err != nil {
|
||||
return "", fmt.Errorf("写入 preload 脚本失败: %w", err)
|
||||
}
|
||||
return preloadPath, nil
|
||||
@@ -207,15 +132,21 @@ func WriteCLIProxyWrapper(cliHTTPSAddr, caCertPath string) error {
|
||||
var content string
|
||||
if runtime.GOOS == "windows" {
|
||||
content = buildWindowsWrapper(port, caCertPath, authToken, cliPath, preloadPath)
|
||||
if err := securefile.Write(filepath.Join(wrapperDir, "launch.ps1"), append([]byte{0xef, 0xbb, 0xbf}, []byte(buildWindowsLauncher(port, caCertPath, authToken, cliPath, preloadPath))...)); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
content = buildUnixWrapper(port, caCertPath, authToken, cliPath, preloadPath)
|
||||
}
|
||||
|
||||
wrapperPath := CLIWrapperPath()
|
||||
if err := os.WriteFile(wrapperPath, []byte(content), 0o755); err != nil {
|
||||
if err := securefile.Write(wrapperPath, []byte(content)); err != nil {
|
||||
return fmt.Errorf("写入 wrapper 脚本失败: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(wrapperPath, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Infof(
|
||||
"writeCLIProxyWrapper: path=%s https_port=%s ca=%s cli=%s preload=%s",
|
||||
wrapperPath, port, caCertPath, cliPath, preloadPath,
|
||||
@@ -225,6 +156,11 @@ func WriteCLIProxyWrapper(cliHTTPSAddr, caCertPath string) error {
|
||||
|
||||
// ClearCLIProxyWrapper 移除 wrapper 脚本、preload 脚本和终端初始化脚本。
|
||||
func ClearCLIProxyWrapper() error {
|
||||
for _, ownedName := range []string{"launch.ps1", "tls-bypass.cjs"} {
|
||||
if err := os.Remove(filepath.Join(CLIWrapperDir(), ownedName)); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
wrapperPath := CLIWrapperPath()
|
||||
if _, err := os.Stat(wrapperPath); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
@@ -387,31 +323,41 @@ func ClearCLIAuthFile() error {
|
||||
}
|
||||
|
||||
func buildWindowsWrapper(port, caCertPath, authToken, cliPath, preloadPath string) string {
|
||||
cliDir := filepath.Dir(cliPath)
|
||||
psScript := filepath.Join(cliDir, "cursor-agent.ps1")
|
||||
return fmt.Sprintf(`@echo off
|
||||
setlocal
|
||||
set "HTTP_PROXY="
|
||||
set "HTTPS_PROXY="
|
||||
set "CLI_HTTPS_PORT=%s"
|
||||
set "NODE_EXTRA_CA_CERTS=%s"
|
||||
set "NODE_TLS_REJECT_UNAUTHORIZED=0"
|
||||
set "NODE_OPTIONS=--require=%s"
|
||||
set "CURSOR_AUTH_TOKEN=%s"
|
||||
set "CURSOR_INVOKED_AS=agent.cmd"
|
||||
%%SystemRoot%%\System32\WindowsPowerShell\v1.0\powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%s" %%*
|
||||
`, port, caCertPath, preloadPath, authToken, psScript)
|
||||
return "@echo off\r\nsetlocal DisableDelayedExpansion\r\n\"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe\" -NoProfile -ExecutionPolicy Bypass -File \"%~dp0launch.ps1\" %*\r\n"
|
||||
}
|
||||
|
||||
func buildWindowsLauncher(port, caCertPath, authToken, cliPath, preloadPath string) string {
|
||||
psScript := filepath.Join(filepath.Dir(cliPath), "cursor-agent.ps1")
|
||||
return fmt.Sprintf(`$ErrorActionPreference='Stop'
|
||||
$env:HTTP_PROXY=''
|
||||
$env:HTTPS_PROXY=''
|
||||
$env:CLI_HTTPS_PORT=%s
|
||||
$env:LOCAL_CURSOR_ORIGINAL_CA_FILE=$env:NODE_EXTRA_CA_CERTS
|
||||
$env:NODE_EXTRA_CA_CERTS=%s
|
||||
if($env:LOCAL_CURSOR_ORIGINAL_CA_FILE -eq $env:NODE_EXTRA_CA_CERTS){$env:LOCAL_CURSOR_ORIGINAL_CA_FILE=''}
|
||||
Remove-Item Env:NODE_TLS_REJECT_UNAUTHORIZED -ErrorAction SilentlyContinue
|
||||
if($env:NODE_OPTIONS -match 'tls-bypass[.]cjs'){$env:NODE_OPTIONS=''}
|
||||
$env:NODE_OPTIONS=($env:NODE_OPTIONS + ' ' + %s).Trim()
|
||||
$env:CURSOR_AUTH_TOKEN=%s
|
||||
$env:CURSOR_INVOKED_AS='agent.cmd'
|
||||
& %s @args
|
||||
exit $LASTEXITCODE
|
||||
`, psLiteral(port), psLiteral(caCertPath), psLiteral("--require="+strconv.Quote(preloadPath)), psLiteral(authToken), psLiteral(psScript))
|
||||
}
|
||||
|
||||
func psLiteral(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" }
|
||||
func shellLiteral(s string) string { return "'" + strings.ReplaceAll(s, "'", `'"'"'`) + "'" }
|
||||
|
||||
func buildUnixWrapper(port, caCertPath, authToken, cliPath, preloadPath string) string {
|
||||
return fmt.Sprintf(`#!/bin/sh
|
||||
unset HTTP_PROXY
|
||||
unset HTTPS_PROXY
|
||||
export CLI_HTTPS_PORT="%s"
|
||||
export NODE_EXTRA_CA_CERTS="%s"
|
||||
export NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
export NODE_OPTIONS="--require=%s"
|
||||
export CURSOR_AUTH_TOKEN="%s"
|
||||
exec "%s" "$@"
|
||||
`, port, caCertPath, preloadPath, authToken, cliPath)
|
||||
unset HTTP_PROXY HTTPS_PROXY NODE_TLS_REJECT_UNAUTHORIZED
|
||||
export CLI_HTTPS_PORT=%s
|
||||
export LOCAL_CURSOR_ORIGINAL_CA_FILE="${NODE_EXTRA_CA_CERTS-}"
|
||||
export NODE_EXTRA_CA_CERTS=%s
|
||||
if [ "$LOCAL_CURSOR_ORIGINAL_CA_FILE" = "$NODE_EXTRA_CA_CERTS" ]; then unset LOCAL_CURSOR_ORIGINAL_CA_FILE; fi
|
||||
case "${NODE_OPTIONS-}" in *tls-bypass.cjs*) unset NODE_OPTIONS;; esac
|
||||
export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }"%s
|
||||
export CURSOR_AUTH_TOKEN=%s
|
||||
exec %s "$@"
|
||||
`, shellLiteral(port), shellLiteral(caCertPath), shellLiteral("--require="+strconv.Quote(preloadPath)), shellLiteral(authToken), shellLiteral(cliPath))
|
||||
}
|
||||
|
||||
43
internal/cursor/cli_preload.cjs
Normal file
43
internal/cursor/cli_preload.cjs
Normal file
@@ -0,0 +1,43 @@
|
||||
// Generated by Local-Cursor. Redirect only Cursor API hosts; keep TLS verification.
|
||||
'use strict';
|
||||
const tls = require('node:tls');
|
||||
const net = require('node:net');
|
||||
const fs = require('node:fs');
|
||||
const {X509Certificate} = require('node:crypto');
|
||||
const port = Number(process.env.CLI_HTTPS_PORT || 39092);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Invalid CLI HTTPS port');
|
||||
const localCA = process.env.NODE_EXTRA_CA_CERTS ? fs.readFileSync(process.env.NODE_EXTRA_CA_CERTS) : undefined;
|
||||
const originalCAPath = process.env.LOCAL_CURSOR_ORIGINAL_CA_FILE;
|
||||
const extraCA = originalCAPath ? (fs.readFileSync(originalCAPath, 'utf8').match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) || []).filter(cert => new X509Certificate(cert).fingerprint256.replaceAll(':','').toLowerCase() !== '836e6bb84f6c3e63316dbb4ec257223af09f7490e7aae09030b8515ed61ee9ff') : [];
|
||||
const redirect = host => /^(?:[a-z0-9-]+\.)*api[23]\.cursor\.sh$/i.test(String(host || ''));
|
||||
|
||||
function normalized(args) {
|
||||
if (args[0] && typeof args[0] === 'object') return { options: { ...args[0] }, callback: args.find(a => typeof a === 'function') };
|
||||
const extra = args.find(a => a && typeof a === 'object') || {};
|
||||
return { options: { ...extra, port: args[0], ...(typeof args[1] === 'string' ? {host: args[1]} : {}) }, callback: args.find(a => typeof a === 'function') };
|
||||
}
|
||||
const originalNetConnect = net.connect;
|
||||
function connect(...args) {
|
||||
const {options, callback} = normalized(args);
|
||||
if (!redirect(options.host)) return originalNetConnect.apply(this, args);
|
||||
options.host = '127.0.0.1'; options.port = port;
|
||||
return originalNetConnect.call(this, options, ...(callback ? [callback] : []));
|
||||
}
|
||||
net.connect = net.createConnection = connect;
|
||||
const originalTLSConnect = tls.connect;
|
||||
tls.connect = function (...args) {
|
||||
const {options, callback} = normalized(args);
|
||||
if (!redirect(options.host)) {
|
||||
if (!extraCA.length || options.ca !== undefined || options.secureContext) return originalTLSConnect.apply(this, args);
|
||||
const defaults = typeof tls.getCACertificates === 'function' ? tls.getCACertificates('default') : tls.rootCertificates;
|
||||
options.ca = [...defaults, ...extraCA];
|
||||
return originalTLSConnect.call(this, options, ...(callback ? [callback] : []));
|
||||
}
|
||||
const originalHost = options.host;
|
||||
options.host = '127.0.0.1'; options.port = port;
|
||||
options.servername = options.servername || originalHost;
|
||||
options.rejectUnauthorized = true;
|
||||
if (!options.ca && extraCA.length) options.ca = [...(typeof tls.getCACertificates === "function" ? tls.getCACertificates("default") : tls.rootCertificates), ...extraCA];
|
||||
if (options.ca && localCA) options.ca = [...(Array.isArray(options.ca) ? options.ca : [options.ca]), localCA];
|
||||
return originalTLSConnect.call(this, options, ...(callback ? [callback] : []));
|
||||
};
|
||||
89
internal/cursor/cli_security_test.go
Normal file
89
internal/cursor/cli_security_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package cursor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cursor/internal/certs"
|
||||
)
|
||||
|
||||
func TestCLIHTTP2TrustSNIAndUnrelatedTLSRejection(t *testing.T) {
|
||||
if _, err := exec.LookPath("node"); err != nil {
|
||||
t.Fatal("Node.js is required for CLI TLS integration tests")
|
||||
}
|
||||
dir := filepath.Join(t.TempDir(), "中文 space ' test")
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager, err := certs.LoadOrCreateLocalCA(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.ProtoMajor != 2 {
|
||||
t.Error("not HTTP/2")
|
||||
}
|
||||
_, _ = w.Write([]byte("secure-http2"))
|
||||
}))
|
||||
server.EnableHTTP2 = true
|
||||
server.TLS = &tls.Config{GetCertificate: func(h *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
return manager.CertificateForServerName(h.ServerName)
|
||||
}}
|
||||
server.StartTLS()
|
||||
defer server.Close()
|
||||
u, _ := url.Parse(server.URL)
|
||||
preload := filepath.Join(dir, cliPreloadFilename)
|
||||
if err = os.WriteFile(preload, []byte(cliPreloadScript), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
good := `const h=require('node:http2');const s=h.connect('https://api2.cursor.sh');s.on('error',e=>{console.error(e.code);process.exitCode=1});const r=s.request({':path':'/'});let b='';r.on('data',d=>b+=d);r.on('end',()=>{if(b!=='secure-http2')process.exitCode=2;s.close()});r.end();`
|
||||
bad := fmt.Sprintf(`const t=require('node:tls');const s=t.connect({host:'127.0.0.1',port:%s},()=>{console.error('accepted invalid host');s.destroy();process.exitCode=2});s.on('error',()=>process.exit(0));`, u.Port())
|
||||
for _, script := range []string{good, bad} {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
cmd := exec.CommandContext(ctx, "node", "-e", script)
|
||||
cmd.Env = append(os.Environ(), "CLI_HTTPS_PORT="+u.Port(), "NODE_EXTRA_CA_CERTS="+filepath.Join(dir, "ca.crt"), "NODE_OPTIONS=--require="+strconv.Quote(preload), "NODE_TLS_REJECT_UNAUTHORIZED=")
|
||||
out, err := cmd.CombinedOutput()
|
||||
cancel()
|
||||
if err != nil {
|
||||
t.Fatalf("CLI integration: %v %s", err, out)
|
||||
}
|
||||
}
|
||||
wrong, err := certs.LoadOrCreateLocalCA(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wrongPath := filepath.Join(dir, "wrong.crt")
|
||||
if err = os.WriteFile(wrongPath, wrong.CACertPEM(), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "node", "-e", `const h=require('node:http2');const s=h.connect('https://api2.cursor.sh');s.on('connect',()=>{s.close();process.exitCode=2});s.on('error',()=>process.exit(0));`)
|
||||
cmd.Env = append(os.Environ(), "CLI_HTTPS_PORT="+u.Port(), "NODE_EXTRA_CA_CERTS="+wrongPath, "NODE_OPTIONS=--require="+strconv.Quote(preload), "NODE_TLS_REJECT_UNAUTHORIZED=")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("wrong-CA rejection: %v %s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapperQuotesPathsAndDoesNotDisableTLS(t *testing.T) {
|
||||
path := `C:\用户 space\O'Brien\tls-redirect.cjs`
|
||||
ps := buildWindowsLauncher("39092", `C:\用户 space\ca.crt`, "test-token", `C:\用户 space\agent.cmd`, path)
|
||||
if strings.Contains(ps, "NODE_TLS_REJECT_UNAUTHORIZED=0") || !strings.Contains(ps, "O''Brien") || !strings.Contains(ps, "--require=") {
|
||||
t.Fatal("unsafe PowerShell launcher")
|
||||
}
|
||||
sh := buildUnixWrapper("39092", "/tmp/user ' space/ca.crt", "test-token", "/tmp/bin ' /agent", "/tmp/user ' space/preload.cjs")
|
||||
if strings.Contains(sh, "REJECT_UNAUTHORIZED=0") || !strings.Contains(sh, `'"'"'`) {
|
||||
t.Fatal("unsafe shell wrapper")
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package cursor
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"crypto/x509"
|
||||
"cursor/internal/certs"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
@@ -84,15 +85,23 @@ func installCACertToDarwinKeychain(certPEM []byte, certPath string) error {
|
||||
|
||||
// EnsureCACertInstalled 确保 CA 证书已安装到 macOS 登录钥匙串。
|
||||
func EnsureCACertInstalled(certPEM []byte, certPath string) error {
|
||||
installed, err := isCACertInstalled(certPEM)
|
||||
if err != nil {
|
||||
return fmt.Errorf("检查 macOS 证书安装状态失败: %w", err)
|
||||
}
|
||||
if installed {
|
||||
logger.Infof("ensureCACertInstalled: cert already installed in macOS login keychain, skipping")
|
||||
return migrateCATrust(certPEM, isCACertInstalled, func() error {
|
||||
if err := installCACertToDarwinKeychain(certPEM, certPath); err != nil {
|
||||
return err
|
||||
}
|
||||
legacy, err := getCertSHA1Fingerprint(certs.EmbeddedCACertPEM())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
exists, err := isCACertInstalled(certs.EmbeddedCACertPEM())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
if out, err := exec.Command(darwinSecurityExe, "delete-certificate", "-t", "-Z", legacy, darwinLoginKeychainName).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("移除旧共享证书失败: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Infof("ensureCACertInstalled: cert not installed in macOS login keychain, installing...")
|
||||
return installCACertToDarwinKeychain(certPEM, certPath)
|
||||
})
|
||||
}
|
||||
|
||||
77
internal/cursor/launcher_windows_test.go
Normal file
77
internal/cursor/launcher_windows_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
//go:build windows
|
||||
|
||||
package cursor
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func utf8BOM(text string) []byte { return append([]byte{0xef, 0xbb, 0xbf}, []byte(text)...) }
|
||||
|
||||
func TestPowerShellLauncherPreservesUnicodePathsAndArguments(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "中文 space O'Brien")
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := filepath.Join(dir, "result.json")
|
||||
fake := `$payload=@{args=@($args);ca=$env:NODE_EXTRA_CA_CERTS;options=$env:NODE_OPTIONS;tls=$env:NODE_TLS_REJECT_UNAUTHORIZED};[IO.File]::WriteAllText(` + psLiteral(result) + `,($payload|ConvertTo-Json -Compress),[Text.Encoding]::UTF8);exit 0`
|
||||
if err := os.WriteFile(filepath.Join(dir, "cursor-agent.ps1"), utf8BOM(fake), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preload := filepath.Join(dir, "tls-redirect.cjs")
|
||||
launcher := filepath.Join(dir, "launch.ps1")
|
||||
if err := os.WriteFile(launcher, utf8BOM(buildWindowsLauncher("39092", filepath.Join(dir, "ca.crt"), "fixture-token", filepath.Join(dir, "agent.cmd"), preload)), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", launcher, "--message", "one two", "a&b", "O'Brien")
|
||||
cmd.Env = append(os.Environ(), "NODE_TLS_REJECT_UNAUTHORIZED=0", "NODE_OPTIONS=--require=old-tls-bypass.cjs")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("launcher: %v %s", err, out)
|
||||
}
|
||||
data, err := os.ReadFile(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data = []byte(strings.TrimPrefix(string(data), "\ufeff"))
|
||||
var got struct {
|
||||
Args []string `json:"args"`
|
||||
CA string `json:"ca"`
|
||||
Options string `json:"options"`
|
||||
TLS string `json:"tls"`
|
||||
}
|
||||
if err = json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"--message", "one two", "a&b", "O'Brien"}
|
||||
if len(got.Args) != len(want) {
|
||||
t.Fatalf("arguments: %#v", got.Args)
|
||||
}
|
||||
for i := range want {
|
||||
if got.Args[i] != want[i] {
|
||||
t.Fatalf("argument %d: %q", i, got.Args[i])
|
||||
}
|
||||
}
|
||||
if got.CA != filepath.Join(dir, "ca.crt") || got.TLS != "" || strings.Contains(got.Options, "tls-bypass") {
|
||||
t.Fatal("unsafe launcher environment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationPowerShellSyntaxWithoutExecutingTrustChanges(t *testing.T) {
|
||||
body := windowsCAMigrationBody(`C:\用户 space O'Brien\ca.crt`, strings.Repeat("A", 40), strings.Repeat("B", 40))
|
||||
path := filepath.Join(t.TempDir(), "migration.ps1")
|
||||
if err := os.WriteFile(path, utf8BOM(body), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parse := `$tokens=$null;$errors=$null;$null=[Management.Automation.Language.Parser]::ParseFile(` + psLiteral(path) + `,[ref]$tokens,[ref]$errors);if($errors.Count){$errors|ForEach-Object{$_.Message};exit 1}`
|
||||
if out, err := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", parse).CombinedOutput(); err != nil {
|
||||
t.Fatalf("invalid migration syntax: %v %s", err, out)
|
||||
}
|
||||
if strings.Contains(body, "CurrentUser") {
|
||||
t.Fatal("elevated script must not target the alternate administrator's user store")
|
||||
}
|
||||
}
|
||||
@@ -14,3 +14,8 @@ func mergeNodeSearchPath() (string, bool) {
|
||||
func detectCursorInstallPathFromRegistry() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// scanDriveRootsForCursor is only used as a Windows fallback.
|
||||
func scanDriveRootsForCursor() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -179,8 +179,16 @@ func DeleteProjectRecord(projectPath string) error {
|
||||
return fmt.Errorf("未找到 Cursor 用户数据目录: %s", userDir)
|
||||
}
|
||||
|
||||
storageRemoved, storageErr := removeWorkspaceStorageEntries(userDir, projectPath)
|
||||
return deleteProjectRecords(userDir, projectPath)
|
||||
}
|
||||
|
||||
func deleteProjectRecords(userDir, projectPath string) error {
|
||||
// Never delete chat data before confirming that the recent-list database can be updated.
|
||||
entryRemoved, entryErr := removeRecentlyOpenedEntry(userDir, projectPath)
|
||||
if entryErr != nil {
|
||||
return entryErr
|
||||
}
|
||||
storageRemoved, storageErr := removeWorkspaceStorageEntries(userDir, projectPath)
|
||||
if storageErr != nil || entryErr != nil {
|
||||
return fmt.Errorf("删除不完整(workspaceStorage: %d 处, %v;最近打开列表: %d 处, %v)",
|
||||
storageRemoved, storageErr, entryRemoved, entryErr)
|
||||
@@ -255,32 +263,40 @@ func removeRecentlyOpenedEntry(userDir string, projectPath string) (int, error)
|
||||
}
|
||||
return 0, fmt.Errorf("读取最近打开列表失败: %w", err)
|
||||
}
|
||||
var payload struct {
|
||||
Entries []struct {
|
||||
FolderURI string `json:"folderUri"`
|
||||
} `json:"entries"`
|
||||
}
|
||||
var payload map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return 0, fmt.Errorf("解析最近打开列表失败: %w", err)
|
||||
}
|
||||
kept := make([]struct {
|
||||
FolderURI string `json:"folderUri"`
|
||||
}, 0, len(payload.Entries))
|
||||
var entries []json.RawMessage
|
||||
if err := json.Unmarshal(payload["entries"], &entries); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
kept := make([]json.RawMessage, 0, len(entries))
|
||||
removed := 0
|
||||
for _, item := range payload.Entries {
|
||||
for _, entry := range entries {
|
||||
var item struct {
|
||||
FolderURI string `json:"folderUri"`
|
||||
}
|
||||
if err := json.Unmarshal(entry, &item); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
path := projectPathFromURI(item.FolderURI)
|
||||
if path != "" && samePath(filepath.Clean(path), projectPath) {
|
||||
removed++
|
||||
continue
|
||||
}
|
||||
kept = append(kept, item)
|
||||
kept = append(kept, entry)
|
||||
}
|
||||
if removed == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
updated, err := json.Marshal(map[string]any{"entries": kept})
|
||||
payload["entries"], err = json.Marshal(kept)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("序列化最近打开列表失败: %w", err)
|
||||
return 0, err
|
||||
}
|
||||
updated, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := db.Exec(
|
||||
"UPDATE ItemTable SET value = ? WHERE key = 'history.recentlyOpenedPathsList'",
|
||||
|
||||
@@ -186,4 +186,4 @@ func TestRemoveWorkspaceStorageEntries(t *testing.T) {
|
||||
t.Fatalf("removed = %d, want 0", removed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
158
internal/cursor/proxy_settings.go
Normal file
158
internal/cursor/proxy_settings.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package cursor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"cursor/internal/appdata"
|
||||
"cursor/internal/securefile"
|
||||
)
|
||||
|
||||
var proxySettingsMu sync.Mutex
|
||||
|
||||
type proxySettingsBackup struct {
|
||||
Original map[string]json.RawMessage `json:"original"`
|
||||
Applied map[string]json.RawMessage `json:"applied"`
|
||||
}
|
||||
|
||||
func settingsBackupPath() string {
|
||||
return filepath.Join(appdata.DataRootPath(), "proxy-settings-backup.json")
|
||||
}
|
||||
func rawEqual(a, b json.RawMessage) bool {
|
||||
var x, y bytes.Buffer
|
||||
if json.Compact(&x, a) != nil || json.Compact(&y, b) != nil {
|
||||
return len(a) == 0 && len(b) == 0
|
||||
}
|
||||
return bytes.Equal(x.Bytes(), y.Bytes())
|
||||
}
|
||||
func loadSettings(path string) (map[string]json.RawMessage, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return map[string]json.RawMessage{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err = normalizeJSONC(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(bytes.TrimSpace(data)) == 0 {
|
||||
return map[string]json.RawMessage{}, nil
|
||||
}
|
||||
var settings map[string]json.RawMessage
|
||||
if err = json.Unmarshal(data, &settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if settings == nil {
|
||||
return nil, fmt.Errorf("Cursor 配置必须是 JSON 对象")
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
func readSettingsBackup() (proxySettingsBackup, error) {
|
||||
backup := proxySettingsBackup{Original: map[string]json.RawMessage{}, Applied: map[string]json.RawMessage{}}
|
||||
data, err := os.ReadFile(settingsBackupPath())
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return backup, nil
|
||||
}
|
||||
if err != nil {
|
||||
return backup, err
|
||||
}
|
||||
err = json.Unmarshal(data, &backup)
|
||||
if err == nil && (backup.Original == nil || backup.Applied == nil) {
|
||||
err = fmt.Errorf("代理配置备份损坏")
|
||||
}
|
||||
return backup, err
|
||||
}
|
||||
|
||||
func WriteUserProxySettings(proxyURL string) error {
|
||||
if proxyURL == "" {
|
||||
return fmt.Errorf("代理地址为空")
|
||||
}
|
||||
proxySettingsMu.Lock()
|
||||
defer proxySettingsMu.Unlock()
|
||||
path, err := resolveCursorSettingsPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings, err := loadSettings(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析 Cursor 配置失败,原文件已保留: %w", err)
|
||||
}
|
||||
backup, err := readSettingsBackup()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
values := map[string]any{"http.proxy": proxyURL, "http.proxyKerberosServicePrincipal": proxyURL, "http.proxySupport": "on", "cursor.general.disableHttp2": true, "http.experimental.systemCertificatesV2": true}
|
||||
for key, value := range values {
|
||||
if previous, exists := settings[key]; exists {
|
||||
if !rawEqual(previous, backup.Applied[key]) {
|
||||
backup.Original[key] = append(json.RawMessage(nil), previous...)
|
||||
}
|
||||
} else {
|
||||
delete(backup.Original, key)
|
||||
}
|
||||
encoded, _ := json.Marshal(value)
|
||||
backup.Applied[key] = encoded
|
||||
settings[key] = encoded
|
||||
}
|
||||
backupData, err := json.Marshal(backup)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = securefile.Write(settingsBackupPath(), backupData); err != nil {
|
||||
return err
|
||||
}
|
||||
// Preserve the product's intentional permanent auto-update setting.
|
||||
settings["update.mode"] = json.RawMessage(`"none"`)
|
||||
data, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return securefile.Write(path, append(data, '\n'))
|
||||
}
|
||||
|
||||
func ClearUserProxySettings() error {
|
||||
proxySettingsMu.Lock()
|
||||
defer proxySettingsMu.Unlock()
|
||||
path, err := resolveCursorSettingsPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
backup, err := readSettingsBackup()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(backup.Applied) == 0 {
|
||||
return nil
|
||||
} // No ownership proof: preserve user values.
|
||||
settings, err := loadSettings(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析 Cursor 配置失败,原文件已保留: %w", err)
|
||||
}
|
||||
for key, applied := range backup.Applied {
|
||||
if rawEqual(settings[key], applied) {
|
||||
if previous, exists := backup.Original[key]; exists {
|
||||
settings[key] = previous
|
||||
} else {
|
||||
delete(settings, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
data, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = securefile.Write(path, append(data, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = os.Remove(settingsBackupPath()); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
167
internal/cursor/security_regression_test.go
Normal file
167
internal/cursor/security_regression_test.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package cursor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"cursor/internal/certs"
|
||||
)
|
||||
|
||||
func isolatedHome(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for _, key := range []string{"HOME", "USERPROFILE", "APPDATA", "XDG_CONFIG_HOME"} {
|
||||
t.Setenv(key, dir)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestProxySettingsRestoreAndPreserveConcurrentUserChange(t *testing.T) {
|
||||
isolatedHome(t)
|
||||
path, err := resolveCursorSettingsPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := []byte(`{"http.proxy":"http://original:8080","editor.fontSize":15,"custom":{"enabled":true}}`)
|
||||
if err = os.WriteFile(path, original, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for range 2 {
|
||||
if err = WriteUserProxySettings("http://127.0.0.1:28080"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
current, err := loadSettings(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current["http.proxySupport"] = json.RawMessage(`"override"`)
|
||||
data, _ := json.Marshal(current)
|
||||
if err = os.WriteFile(path, data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = ClearUserProxySettings(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := loadSettings(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !rawEqual(got["http.proxy"], json.RawMessage(`"http://original:8080"`)) {
|
||||
t.Fatal("original proxy not restored")
|
||||
}
|
||||
if !rawEqual(got["http.proxySupport"], json.RawMessage(`"override"`)) {
|
||||
t.Fatal("user edit overwritten")
|
||||
}
|
||||
if !rawEqual(got["custom"], json.RawMessage(`{"enabled":true}`)) {
|
||||
t.Fatal("unrelated settings lost")
|
||||
}
|
||||
if _, ok := got["cursor.general.disableHttp2"]; ok {
|
||||
t.Fatal("injected key not removed")
|
||||
}
|
||||
if err = ClearUserProxySettings(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidSettingsNeverDeleted(t *testing.T) {
|
||||
isolatedHome(t)
|
||||
path, _ := resolveCursorSettingsPath()
|
||||
_ = os.MkdirAll(filepath.Dir(path), 0700)
|
||||
bad := []byte("{broken user settings // keep me")
|
||||
if err := os.WriteFile(path, bad, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := WriteUserProxySettings("http://127.0.0.1:28080"); err == nil {
|
||||
t.Fatal("invalid settings accepted")
|
||||
}
|
||||
after, _ := os.ReadFile(path)
|
||||
if !bytes.Equal(bad, after) {
|
||||
t.Fatal("user settings destroyed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertificateMigrationCancellationAndRetry(t *testing.T) {
|
||||
newCert := []byte("local CA")
|
||||
current, legacy := false, true
|
||||
trusted := func(cert []byte) (bool, error) {
|
||||
if bytes.Equal(cert, certs.EmbeddedCACertPEM()) {
|
||||
return legacy, nil
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
denied := errors.New("authorization canceled")
|
||||
if err := migrateCATrust(newCert, trusted, func() error { return denied }); !errors.Is(err, denied) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if current || !legacy {
|
||||
t.Fatal("cancellation changed trust")
|
||||
}
|
||||
if err := migrateCATrust(newCert, trusted, func() error { current = true; legacy = false; return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migrateCATrust(newCert, trusted, func() error { t.Fatal("migration not idempotent"); return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy = true
|
||||
if err := migrateCATrust(newCert, trusted, func() error { return nil }); err == nil {
|
||||
t.Fatal("legacy trust silently retained")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentlyOpenedPreservesUnknownFields(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
createStateDB(t, dir, []string{"file:///d:/Project/A", "file:///d:/Project/B"})
|
||||
db, err := sql.Open("sqlite", filepath.Join(dir, "globalStorage", "state.vscdb"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
raw := `{"version":3,"metadata":{"keep":true},"entries":[{"folderUri":"file:///d:/Project/A","label":"remove"},{"folderUri":"file:///d:/Project/B","label":"keep","remoteAuthority":"ssh-remote+host"},{"fileUri":"file:///d:/readme.md","label":"file"}]}`
|
||||
if _, err = db.Exec("UPDATE ItemTable SET value = ? WHERE key = 'history.recentlyOpenedPathsList'", raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, err := removeRecentlyOpenedEntry(dir, "d:/Project/A"); err != nil || n != 1 {
|
||||
t.Fatalf("removed=%d err=%v", n, err)
|
||||
}
|
||||
var updated string
|
||||
if err = db.QueryRow("SELECT value FROM ItemTable WHERE key = 'history.recentlyOpenedPathsList'").Scan(&updated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got map[string]json.RawMessage
|
||||
if err = json.Unmarshal([]byte(updated), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !rawEqual(got["metadata"], json.RawMessage(`{"keep":true}`)) || !rawEqual(got["version"], json.RawMessage(`3`)) {
|
||||
t.Fatal("top-level metadata lost")
|
||||
}
|
||||
var entries []map[string]any
|
||||
_ = json.Unmarshal(got["entries"], &entries)
|
||||
if len(entries) != 2 || entries[0]["remoteAuthority"] != "ssh-remote+host" || entries[1]["fileUri"] != "file:///d:/readme.md" {
|
||||
t.Fatalf("unmatched entries changed: %s", updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseFailurePreservesWorkspaceChatData(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
workspace := filepath.Join(dir, "workspaceStorage", "hash")
|
||||
_ = os.MkdirAll(workspace, 0700)
|
||||
_ = os.WriteFile(filepath.Join(workspace, "workspace.json"), []byte(`{"folder":"file:///d:/Project/A"}`), 0600)
|
||||
_ = os.WriteFile(filepath.Join(workspace, "chat.db"), []byte("important user chat"), 0600)
|
||||
_ = os.MkdirAll(filepath.Join(dir, "globalStorage"), 0700)
|
||||
_ = os.WriteFile(filepath.Join(dir, "globalStorage", "state.vscdb"), []byte("corrupt database"), 0600)
|
||||
if err := deleteProjectRecords(dir, "d:/Project/A"); err == nil {
|
||||
t.Fatal("corrupt database accepted")
|
||||
}
|
||||
if data, err := os.ReadFile(filepath.Join(workspace, "chat.db")); err != nil || string(data) != "important user chat" {
|
||||
t.Fatal("chat data removed before database update")
|
||||
}
|
||||
}
|
||||
@@ -30,12 +30,6 @@ var injectedCursorSettingsKeys = []string{
|
||||
// EnsureCACertFile 用于处理与 EnsureCACertFile 相关的逻辑。
|
||||
func EnsureCACertFile(certPEM []byte, currentPath string) (string, error) {
|
||||
certPath := appdata.CACertFilePath()
|
||||
if samePath(strings.TrimSpace(currentPath), certPath) {
|
||||
if _, err := os.Stat(certPath); err == nil {
|
||||
logger.Infof("ensureCACertFile: reusing path=%s", certPath)
|
||||
return certPath, nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(certPath), 0o755); err != nil {
|
||||
return "", fmt.Errorf("创建证书配置目录失败: %w", err)
|
||||
@@ -65,6 +59,9 @@ func samePath(left string, right string) bool {
|
||||
if strings.TrimSpace(left) == "" || strings.TrimSpace(right) == "" {
|
||||
return false
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.EqualFold(filepath.Clean(left), filepath.Clean(right))
|
||||
}
|
||||
return filepath.Clean(left) == filepath.Clean(right)
|
||||
}
|
||||
|
||||
@@ -117,125 +114,6 @@ func ClearSystemNodeExtraCACerts() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteUserProxySettings 用于处理与 WriteUserProxySettings 相关的逻辑。
|
||||
func WriteUserProxySettings(proxyURL string) error {
|
||||
proxyURL = strings.TrimSpace(proxyURL)
|
||||
if proxyURL == "" {
|
||||
return errors.New("代理地址为空")
|
||||
}
|
||||
|
||||
settingsPath, err := resolveCursorSettingsPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil {
|
||||
return fmt.Errorf("创建 Cursor 配置目录失败: %w", err)
|
||||
}
|
||||
|
||||
settings := make(map[string]any)
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("读取 Cursor 配置失败: %w", err)
|
||||
}
|
||||
} else if len(bytes.TrimSpace(data)) > 0 {
|
||||
parsed, err := decodeCursorSettingsJSONC(data)
|
||||
if err != nil {
|
||||
if removeErr := os.Remove(settingsPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
|
||||
return fmt.Errorf("解析 Cursor 配置失败,且删除损坏配置失败: %w", removeErr)
|
||||
}
|
||||
logger.Infof("writeCursorUserProxySettings: removed invalid settings path=%s err=%v", settingsPath, err)
|
||||
data = nil
|
||||
} else {
|
||||
settings = parsed
|
||||
}
|
||||
}
|
||||
|
||||
settings["http.proxy"] = proxyURL
|
||||
settings["http.proxyKerberosServicePrincipal"] = proxyURL
|
||||
settings["http.proxySupport"] = "on"
|
||||
settings["cursor.general.disableHttp2"] = true
|
||||
settings["http.experimental.systemCertificatesV2"] = true
|
||||
// update.mode 写入后永久保留,不随 ClearUserProxySettings 清除,避免恢复自动更新。
|
||||
settings["update.mode"] = "none"
|
||||
|
||||
encoded, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化 Cursor 配置失败: %w", err)
|
||||
}
|
||||
encoded = append(encoded, '\n')
|
||||
|
||||
if len(bytes.TrimSpace(data)) > 0 && bytes.Equal(data, encoded) {
|
||||
logger.Infof("writeCursorUserProxySettings: unchanged path=%s proxy=%s", settingsPath, proxyURL)
|
||||
return nil
|
||||
}
|
||||
|
||||
tempPath := settingsPath + ".tmp"
|
||||
if err := os.WriteFile(tempPath, encoded, 0o644); err != nil {
|
||||
return fmt.Errorf("写入 Cursor 配置临时文件失败: %w", err)
|
||||
}
|
||||
if err := os.Rename(tempPath, settingsPath); err != nil {
|
||||
return fmt.Errorf("保存 Cursor 配置失败: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("writeCursorUserProxySettings: path=%s proxy=%s", settingsPath, proxyURL)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearUserProxySettings 用于处理与 ClearUserProxySettings 相关的逻辑。
|
||||
func ClearUserProxySettings() error {
|
||||
settingsPath, err := resolveCursorSettingsPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("读取 Cursor 配置失败: %w", err)
|
||||
}
|
||||
if len(bytes.TrimSpace(data)) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
settings := make(map[string]any)
|
||||
parsed, err := decodeCursorSettingsJSONC(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析 Cursor 配置失败: %w", err)
|
||||
}
|
||||
settings = parsed
|
||||
|
||||
changed := false
|
||||
for _, key := range injectedCursorSettingsKeys {
|
||||
if _, exists := settings[key]; exists {
|
||||
delete(settings, key)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
|
||||
encoded, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化 Cursor 配置失败: %w", err)
|
||||
}
|
||||
encoded = append(encoded, '\n')
|
||||
|
||||
tempPath := settingsPath + ".tmp"
|
||||
if err := os.WriteFile(tempPath, encoded, 0o644); err != nil {
|
||||
return fmt.Errorf("写入 Cursor 配置临时文件失败: %w", err)
|
||||
}
|
||||
if err := os.Rename(tempPath, settingsPath); err != nil {
|
||||
return fmt.Errorf("保存 Cursor 配置失败: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("clearCursorUserProxySettings: path=%s", settingsPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveCursorSettingsPath 用于处理与 resolveCursorSettingsPath 相关的逻辑。
|
||||
func resolveCursorSettingsPath() (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
|
||||
@@ -5,12 +5,16 @@ package cursor
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"crypto/x509"
|
||||
"cursor/internal/certs"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unicode/utf16"
|
||||
|
||||
"cursor/internal/logger"
|
||||
)
|
||||
@@ -147,16 +151,62 @@ func installCACertToWindowsStore(certPEM []byte, certPath string) error {
|
||||
|
||||
// EnsureCACertInstalled 确保证书已安装到 Windows 系统信任存储。
|
||||
func EnsureCACertInstalled(certPEM []byte, certPath string) error {
|
||||
installed, err := isCACertInstalled(certPEM)
|
||||
if err != nil {
|
||||
return fmt.Errorf("检查系统证书安装状态失败: %w", err)
|
||||
}
|
||||
|
||||
if installed {
|
||||
logger.Infof("ensureCACertInstalled: cert already installed in system store, skipping")
|
||||
return migrateCATrust(certPEM, isCAInstalledAnyStore, func() error {
|
||||
thumb, err := getCertThumbprint(certPEM)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
legacy, err := getCertThumbprint(certs.EmbeddedCACertPEM())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := windowsCAMigrationBody(certPath, thumb, legacy)
|
||||
units := utf16.Encode([]rune(body))
|
||||
bytes := make([]byte, len(units)*2)
|
||||
for i, u := range units {
|
||||
binary.LittleEndian.PutUint16(bytes[i*2:], u)
|
||||
}
|
||||
encoded := base64.StdEncoding.EncodeToString(bytes)
|
||||
outer := "$p=Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile','-NonInteractive','-EncodedCommand','" + encoded + "') -Verb RunAs -WindowStyle Hidden -Wait -PassThru; exit $p.ExitCode"
|
||||
cmd := exec.Command(windowsPowerShellExe, "-NoProfile", "-NonInteractive", "-Command", outer)
|
||||
cmd.SysProcAttr = hideWindow()
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("安装证书授权失败或已取消,请重试: %w", err)
|
||||
}
|
||||
cleanup := fmt.Sprintf("$ErrorActionPreference='Stop'; $s=New-Object System.Security.Cryptography.X509Certificates.X509Store('Root','CurrentUser'); try { $s.Open('ReadWrite'); foreach($c in @($s.Certificates.Find('FindByThumbprint',%s,$false))) { $s.Remove($c) } } finally { $s.Close() }", quotePowerShellLiteral(legacy))
|
||||
originalUser := exec.Command(windowsPowerShellExe, "-NoProfile", "-NonInteractive", "-Command", cleanup)
|
||||
originalUser.SysProcAttr = hideWindow()
|
||||
if err := originalUser.Run(); err != nil {
|
||||
return fmt.Errorf("移除当前用户的旧共享证书失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Infof("ensureCACertInstalled: cert not installed in system store, installing...")
|
||||
return installCACertToWindowsStore(certPEM, certPath)
|
||||
})
|
||||
}
|
||||
|
||||
func isCAInstalledAnyStore(certPEM []byte) (bool, error) {
|
||||
thumb, err := getCertThumbprint(certPEM)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
script := "$ErrorActionPreference='Stop'; (Test-Path " + quotePowerShellLiteral(`Cert:\LocalMachine\Root\`+thumb) + ") -or (Test-Path " + quotePowerShellLiteral(`Cert:\CurrentUser\Root\`+thumb) + ")"
|
||||
cmd := exec.Command(windowsPowerShellExe, "-NoProfile", "-NonInteractive", "-Command", script)
|
||||
cmd.SysProcAttr = hideWindow()
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(string(out)), "True"), nil
|
||||
}
|
||||
|
||||
func windowsCAMigrationBody(certPath, thumb, legacy string) string {
|
||||
return fmt.Sprintf(`$ErrorActionPreference='Stop'
|
||||
$c = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(%s)
|
||||
if ($c.Thumbprint -ne %s) { throw 'CA fingerprint mismatch' }
|
||||
$s = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root','LocalMachine')
|
||||
try {
|
||||
$s.Open('ReadWrite'); $s.Add($c)
|
||||
if ($s.Certificates.Find('FindByThumbprint',%s,$false).Count -eq 0) { throw 'CA installation failed' }
|
||||
foreach ($old in @($s.Certificates.Find('FindByThumbprint',%s,$false))) { $s.Remove($old) }
|
||||
} finally { $s.Close() }
|
||||
`, quotePowerShellLiteral(certPath), quotePowerShellLiteral(thumb), quotePowerShellLiteral(thumb), quotePowerShellLiteral(legacy))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user