Ð޸´ȫÏîĿÉó²éÎÊÌⲢ·¢²¼ v1.1.9

¸ÄÓñ¾»ú¶ÀÁ¢ CA£¬»ָ´ CLI TLS УÑ飬±£»¤ƾ¾ÝÓëÅäÖò¢·¢±£´档
Ð޸´ Cursor Êý¾ݡ¢»Ự·¾¶¡¢´úÀíÉúÃüÖÜÆڡ¢ģÐÍÇëÇóÓë Skills Îļþ²Ù×÷¡£
Ϊ¶ÀÁ¢ Tab ·þÎñÔö¼ӿͻ§¶˼øȨ£¬Éý¼¶ÒÀÀµÓ빤¾ßÁ´£¬ÍêÉƷ¢²¼ԪÐÅϢͬ²½¡£

Ñé֤£ºÁ½¸ö Go ģ¿éµIJâÊÔ/vet¡¢¹ؼüģ¿é race¡¢ǰ¶˲âÊÔ/Éú²ú¹¹½¨/Éó¼ơ¢
Go ¿ɴï©¶´ɨÃèÓë macOS/Linux ¿ÉÐÐģ¿齻²æ±àÒë¾ùͨ¹ý¡£
Windows ¸ôÀëÆô¶¯ͨ¹ý£»δÍê³ɵĵã»÷ðÑ̺ÍԭÉúƽ̨Ñé֤¼ûÉó²鱨¸档
This commit is contained in:
FuDie0915
2026-11-10 09:59:34 +08:00
parent db73cf5a39
commit 188225fae6
98 changed files with 3324 additions and 930 deletions

View 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
}

View File

@@ -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))
}

View 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] : []));
};

View 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")
}
}

View File

@@ -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)
})
}

View 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")
}
}

View File

@@ -14,3 +14,8 @@ func mergeNodeSearchPath() (string, bool) {
func detectCursorInstallPathFromRegistry() string {
return ""
}
// scanDriveRootsForCursor is only used as a Windows fallback.
func scanDriveRootsForCursor() string {
return ""
}

View File

@@ -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'",

View File

@@ -186,4 +186,4 @@ func TestRemoveWorkspaceStorageEntries(t *testing.T) {
t.Fatalf("removed = %d, want 0", removed)
}
})
}
}

View 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
}

View 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")
}
}

View File

@@ -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()

View File

@@ -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))
}