feat(connection-import): 支持导入 Navicat NCX 连接与密码

- 新增 Navicat NCX XML 解析与 v1/v2 密码解密能力
- 接入后端连接导入链路并补充导入失败提示
- 前端补充 NCX 格式识别、缺失密码提示与定向测试
This commit is contained in:
Syngnat
2026-06-07 13:38:53 +08:00
parent ace6e18da8
commit 79c5bfb3d4
6 changed files with 723 additions and 3 deletions

View File

@@ -2058,14 +2058,14 @@ function App() {
const importKind = detectConnectionImportKind(raw);
if (importKind === 'invalid') {
void message.error('文件格式错误:仅支持 GoNavi 恢复包、历史 JSON 连接数组MySQL Workbench XML');
void message.error('文件格式错误:仅支持 GoNavi 恢复包、历史 JSON 连接数组MySQL Workbench XML 或 Navicat NCX');
return;
}
try {
setPendingConnectionImportPayload(null);
const importedViews = await importConnectionsPayload(raw, '');
if (importKind === 'mysql-workbench-xml' && importedViews.some(v => !v.hasPrimaryPassword)) {
if ((importKind === 'mysql-workbench-xml' || importKind === 'navicat-ncx') && importedViews.some(v => !v.hasPrimaryPassword)) {
void message.warning(`成功导入 ${importedViews.length} 个连接,部分连接未包含密码,请编辑对应连接并输入密码后保存`);
} else {
void message.success(`成功导入 ${importedViews.length} 个连接`);

View File

@@ -88,6 +88,13 @@ describe('connectionExport', () => {
]))).toBe('legacy-json');
});
it('detects Navicat NCX xml exports', () => {
expect(detectConnectionImportKind(`<?xml version="1.0" encoding="UTF-8"?>
<Connections>
<Connection ConnType="MYSQL" ConnectionName="Local MySQL" Host="127.0.0.1" Port="3306" UserName="root" Password="ABCD" SavePassword="true" />
</Connections>`)).toBe('navicat-ncx');
});
it('returns invalid for malformed or unsupported content', () => {
expect(detectConnectionImportKind('{not-json}')).toBe('invalid');
expect(detectConnectionImportKind(JSON.stringify({

View File

@@ -1,6 +1,6 @@
import type { ConnectionConfig, SavedConnection } from '../types';
export type ConnectionImportKind = 'app-managed-package' | 'encrypted-package' | 'legacy-json' | 'mysql-workbench-xml' | 'invalid';
export type ConnectionImportKind = 'app-managed-package' | 'encrypted-package' | 'legacy-json' | 'mysql-workbench-xml' | 'navicat-ncx' | 'invalid';
export type ConnectionPackageDialogSnapshot = {
open: boolean;
mode: 'export' | 'import';
@@ -109,10 +109,19 @@ const isMySQLWorkbenchXML = (raw: string): boolean => (
raw.includes('<data') && raw.includes('grt_format') && raw.includes('db.mgmt.Connection')
);
const isNavicatNCX = (raw: string): boolean => (
raw.includes('<Connection')
&& raw.includes('ConnType=')
&& raw.includes('ConnectionName=')
);
export const detectConnectionImportKind = (raw: unknown): ConnectionImportKind => {
if (typeof raw === 'string' && isMySQLWorkbenchXML(raw)) {
return 'mysql-workbench-xml';
}
if (typeof raw === 'string' && isNavicatNCX(raw)) {
return 'navicat-ncx';
}
const parsed = parseConnectionImportRaw(raw);

View File

@@ -285,6 +285,21 @@ func (a *App) ImportConnectionsPayload(raw string, password string) ([]connectio
return sanitizeSavedConnectionViews(views), nil
}
if isNavicatNCX(trimmed) {
inputs, err := parseNavicatNCX(trimmed)
if err != nil {
return nil, fmt.Errorf("解析 Navicat NCX 失败: %w", err)
}
if len(inputs) == 0 {
return nil, fmt.Errorf("未在 Navicat NCX 中找到 GoNavi 支持的有效连接配置")
}
views, err := a.importSavedConnectionsAtomically(inputs)
if err != nil {
return nil, err
}
return sanitizeSavedConnectionViews(views), nil
}
var legacy []connection.LegacySavedConnection
if err := json.Unmarshal([]byte(trimmed), &legacy); err != nil {
return nil, errConnectionPackageUnsupported

View File

@@ -0,0 +1,470 @@
package app
import (
"crypto/aes"
"crypto/sha1"
"encoding/hex"
"encoding/xml"
"errors"
"fmt"
"strconv"
"strings"
"unicode"
"unicode/utf8"
"GoNavi-Wails/internal/connection"
"github.com/google/uuid"
"golang.org/x/crypto/blowfish"
)
var errNavicatSecretDecryptFailed = errors.New("解密 Navicat 密码失败")
type navicatNCXDocument struct {
Connections []navicatNCXConnection `xml:"Connection"`
}
type navicatNCXConnection struct {
ConnType string `xml:"ConnType,attr"`
ConnectionName string `xml:"ConnectionName,attr"`
Host string `xml:"Host,attr"`
Port string `xml:"Port,attr"`
Database string `xml:"Database,attr"`
DatabaseFileName string `xml:"DatabaseFileName,attr"`
UserName string `xml:"UserName,attr"`
Password string `xml:"Password,attr"`
SavePassword string `xml:"SavePassword,attr"`
OraServiceNameType string `xml:"OraServiceNameType,attr"`
TNS string `xml:"TNS,attr"`
SSL string `xml:"SSL,attr"`
SSLPGSSLMode string `xml:"SSL_PGSSLMode,attr"`
SSLClientKey string `xml:"SSL_ClientKey,attr"`
SSLClientCert string `xml:"SSL_ClientCert,attr"`
SSLCACert string `xml:"SSL_CACert,attr"`
SSLWeakCertValidation string `xml:"SSL_WeakCertValidation,attr"`
SSLAllowInvalidHostName string `xml:"SSL_AllowInvalidHostName,attr"`
SSH string `xml:"SSH,attr"`
SSHHost string `xml:"SSH_Host,attr"`
SSHPort string `xml:"SSH_Port,attr"`
SSHUserName string `xml:"SSH_UserName,attr"`
SSHAuthenMethod string `xml:"SSH_AuthenMethod,attr"`
SSHPassword string `xml:"SSH_Password,attr"`
SSHSavePassword string `xml:"SSH_SavePassword,attr"`
SSHPrivateKey string `xml:"SSH_PrivateKey,attr"`
HTTPProxy string `xml:"HTTP_Proxy,attr"`
HTTPProxyHost string `xml:"HTTP_Proxy_Host,attr"`
HTTPProxyPort string `xml:"HTTP_Proxy_Port,attr"`
HTTPProxyUserName string `xml:"HTTP_Proxy_UserName,attr"`
HTTPProxyPassword string `xml:"HTTP_Proxy_Password,attr"`
HTTPProxySavePassword string `xml:"HTTP_Proxy_SavePassword,attr"`
}
type navicatCryptoV1 struct {
block *blowfish.Cipher
initialVector []byte
}
func isNavicatNCX(content string) bool {
text := strings.TrimSpace(content)
return strings.Contains(text, "<Connection") &&
strings.Contains(text, "ConnType=") &&
strings.Contains(text, "ConnectionName=")
}
func parseNavicatNCX(content string) ([]connection.SavedConnectionInput, error) {
var doc navicatNCXDocument
if err := xml.Unmarshal([]byte(content), &doc); err != nil {
return nil, err
}
inputs := make([]connection.SavedConnectionInput, 0, len(doc.Connections))
for _, item := range doc.Connections {
input, ok, err := parseNavicatNCXConnection(item)
if err != nil {
return nil, err
}
if ok {
inputs = append(inputs, input)
}
}
return inputs, nil
}
func parseNavicatNCXConnection(item navicatNCXConnection) (connection.SavedConnectionInput, bool, error) {
configType, defaultPort, supported := resolveNavicatConnectionType(item.ConnType)
if !supported {
return connection.SavedConnectionInput{}, false, nil
}
connectionID := "conn-" + uuid.New().String()[:8]
config := connection.ConnectionConfig{
ID: connectionID,
Type: configType,
Port: parseNavicatInt(item.Port, defaultPort),
User: strings.TrimSpace(item.UserName),
}
if configType == "sqlite" {
filePath := firstNonEmpty(item.DatabaseFileName, item.Database)
filePath = strings.TrimSpace(filePath)
if filePath == "" {
return connection.SavedConnectionInput{}, false, nil
}
config.Host = filePath
config.Database = filePath
} else {
config.Host = strings.TrimSpace(item.Host)
config.Database = strings.TrimSpace(item.Database)
if configType == "oracle" && strings.TrimSpace(config.Database) == "" {
config.Database = strings.TrimSpace(item.TNS)
}
if configType == "oracle" && strings.EqualFold(strings.TrimSpace(item.OraServiceNameType), "SID") && strings.TrimSpace(config.Database) != "" {
config.ConnectionParams = "SID=" + strings.TrimSpace(config.Database)
}
}
if configType == "redis" {
if dbIndex, ok := parseNavicatRedisDatabase(item.Database); ok {
config.RedisDB = dbIndex
}
config.Database = ""
}
password, err := decodeNavicatSecret(item.Password)
if err != nil {
return connection.SavedConnectionInput{}, false, fmt.Errorf("连接 %s 的密码解析失败: %w", resolveNavicatConnectionName(item, config), err)
}
config.Password = password
applyNavicatSSLConfig(&config, item)
sshPassword, err := decodeNavicatSecret(item.SSHPassword)
if err != nil {
return connection.SavedConnectionInput{}, false, fmt.Errorf("连接 %s 的 SSH 密码解析失败: %w", resolveNavicatConnectionName(item, config), err)
}
applyNavicatSSHConfig(&config, item, sshPassword)
proxyPassword, err := decodeNavicatSecret(item.HTTPProxyPassword)
if err != nil {
return connection.SavedConnectionInput{}, false, fmt.Errorf("连接 %s 的代理密码解析失败: %w", resolveNavicatConnectionName(item, config), err)
}
applyNavicatHTTPProxyConfig(&config, item, proxyPassword)
name := resolveNavicatConnectionName(item, config)
return connection.SavedConnectionInput{
ID: connectionID,
Name: name,
Config: config,
}, true, nil
}
func resolveNavicatConnectionType(raw string) (string, int, bool) {
switch strings.ToUpper(strings.TrimSpace(raw)) {
case "MYSQL":
return "mysql", 3306, true
case "MARIADB":
return "mariadb", 3306, true
case "POSTGRES", "POSTGRESQL":
return "postgres", 5432, true
case "SQLSERVER", "MSSQL":
return "sqlserver", 1433, true
case "SQLITE", "SQLITE3":
return "sqlite", 0, true
case "ORACLE":
return "oracle", 1521, true
case "REDIS":
return "redis", 6379, true
case "MONGODB", "MONGO":
return "mongodb", 27017, true
case "CLICKHOUSE":
return "clickhouse", 9000, true
case "DAMENG", "DM":
return "dameng", 5236, true
default:
return "", 0, false
}
}
func resolveNavicatConnectionName(item navicatNCXConnection, config connection.ConnectionConfig) string {
name := strings.TrimSpace(item.ConnectionName)
if name != "" {
return name
}
if config.Type == "sqlite" {
return strings.TrimSpace(config.Database)
}
if strings.TrimSpace(config.User) != "" && strings.TrimSpace(config.Host) != "" {
return fmt.Sprintf("%s@%s:%d", strings.TrimSpace(config.User), strings.TrimSpace(config.Host), config.Port)
}
return strings.TrimSpace(config.Host)
}
func parseNavicatInt(raw string, fallback int) int {
value, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || value <= 0 {
return fallback
}
return value
}
func parseNavicatBool(raw string) bool {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
func parseNavicatRedisDatabase(raw string) (int, bool) {
value, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || value < 0 {
return 0, false
}
return value, true
}
func applyNavicatSSLConfig(config *connection.ConnectionConfig, item navicatNCXConnection) {
useSSL := parseNavicatBool(item.SSL)
if !useSSL {
return
}
config.UseSSL = true
config.SSLCAPath = strings.TrimSpace(item.SSLCACert)
config.SSLCertPath = strings.TrimSpace(item.SSLClientCert)
config.SSLKeyPath = strings.TrimSpace(item.SSLClientKey)
if parseNavicatBool(item.SSLWeakCertValidation) || parseNavicatBool(item.SSLAllowInvalidHostName) {
config.SSLMode = "skip-verify"
return
}
if config.Type == "postgres" {
config.SSLMode = resolveNavicatPostgresSSLMode(item.SSLPGSSLMode)
if strings.TrimSpace(config.SSLMode) == "" {
config.SSLMode = "required"
}
return
}
config.SSLMode = "required"
}
func resolveNavicatPostgresSSLMode(raw string) string {
switch strings.ToUpper(strings.TrimSpace(raw)) {
case "ALLOW", "PREFER":
return "preferred"
case "REQUIRE", "VERIFY-CA", "VERIFY_CA", "VERIFY-FULL", "VERIFY_FULL":
return "required"
case "DISABLE":
return "disable"
default:
return ""
}
}
func applyNavicatSSHConfig(config *connection.ConnectionConfig, item navicatNCXConnection, password string) {
if !parseNavicatBool(item.SSH) {
return
}
config.UseSSH = true
config.SSH = connection.SSHConfig{
Host: strings.TrimSpace(item.SSHHost),
Port: parseNavicatInt(item.SSHPort, 22),
User: strings.TrimSpace(item.SSHUserName),
Password: password,
KeyPath: strings.TrimSpace(item.SSHPrivateKey),
}
}
func applyNavicatHTTPProxyConfig(config *connection.ConnectionConfig, item navicatNCXConnection, password string) {
if !parseNavicatBool(item.HTTPProxy) {
return
}
config.UseProxy = true
config.Proxy = connection.ProxyConfig{
Type: "http",
Host: strings.TrimSpace(item.HTTPProxyHost),
Port: parseNavicatInt(item.HTTPProxyPort, 8080),
User: strings.TrimSpace(item.HTTPProxyUserName),
Password: password,
}
}
func decodeNavicatSecret(raw string) (string, error) {
text := strings.TrimSpace(raw)
if text == "" {
return "", nil
}
if !looksLikeNavicatHex(text) {
return text, nil
}
plain, err := decryptNavicatHexSecret(text)
if err != nil {
return "", err
}
return plain, nil
}
func looksLikeNavicatHex(raw string) bool {
text := strings.TrimSpace(raw)
if text == "" || len(text)%2 != 0 {
return false
}
for _, ch := range text {
if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F') {
continue
}
return false
}
return true
}
func decryptNavicatHexSecret(raw string) (string, error) {
ciphertext, err := hex.DecodeString(strings.TrimSpace(raw))
if err != nil {
return "", err
}
if plaintext, err := decryptNavicatHexSecretV1(ciphertext); err == nil {
return plaintext, nil
}
if plaintext, err := decryptNavicatHexSecretV2(ciphertext); err == nil {
return plaintext, nil
}
return "", errNavicatSecretDecryptFailed
}
func newNavicatCryptoV1() (*navicatCryptoV1, error) {
sum := sha1.Sum([]byte("3DC5CA39"))
block, err := blowfish.NewCipher(sum[:])
if err != nil {
return nil, err
}
initialVector := bytesRepeat(0xFF, block.BlockSize())
block.Encrypt(initialVector, initialVector)
return &navicatCryptoV1{
block: block,
initialVector: initialVector,
}, nil
}
func decryptNavicatHexSecretV1(ciphertext []byte) (string, error) {
cryptoV1, err := newNavicatCryptoV1()
if err != nil {
return "", err
}
return cryptoV1.decrypt(ciphertext)
}
func (c *navicatCryptoV1) decrypt(ciphertext []byte) (string, error) {
blockSize := c.block.BlockSize()
currentVector := append([]byte(nil), c.initialVector...)
roundBytes := len(ciphertext) / blockSize * blockSize
plaintext := make([]byte, 0, len(ciphertext))
decryptedBlock := make([]byte, blockSize)
for offset := 0; offset < roundBytes; offset += blockSize {
c.block.Decrypt(decryptedBlock, ciphertext[offset:offset+blockSize])
plaintext = append(plaintext, xorBytes(decryptedBlock, currentVector)...)
currentVector = xorBytes(currentVector, ciphertext[offset:offset+blockSize])
}
if leftover := len(ciphertext) - roundBytes; leftover > 0 {
stream := make([]byte, blockSize)
c.block.Encrypt(stream, currentVector)
plaintext = append(plaintext, xorBytes(ciphertext[roundBytes:], stream[:leftover])...)
}
if !isLikelyNavicatPlaintext(plaintext) {
return "", errNavicatSecretDecryptFailed
}
return string(plaintext), nil
}
func decryptNavicatHexSecretV2(ciphertext []byte) (string, error) {
block, err := aes.NewCipher([]byte("libcckeylibcckey"))
if err != nil {
return "", err
}
if len(ciphertext) == 0 || len(ciphertext)%block.BlockSize() != 0 {
return "", errNavicatSecretDecryptFailed
}
plaintext := make([]byte, len(ciphertext))
currentVector := []byte("libcciv libcciv ")
for offset := 0; offset < len(ciphertext); offset += block.BlockSize() {
block.Decrypt(plaintext[offset:offset+block.BlockSize()], ciphertext[offset:offset+block.BlockSize()])
for i := 0; i < block.BlockSize(); i++ {
plaintext[offset+i] ^= currentVector[i]
}
currentVector = ciphertext[offset : offset+block.BlockSize()]
}
plaintext, err = stripPKCS7Padding(plaintext, block.BlockSize())
if err != nil || !isLikelyNavicatPlaintext(plaintext) {
return "", errNavicatSecretDecryptFailed
}
return string(plaintext), nil
}
func stripPKCS7Padding(data []byte, blockSize int) ([]byte, error) {
if len(data) == 0 || len(data)%blockSize != 0 {
return nil, errNavicatSecretDecryptFailed
}
paddingLength := int(data[len(data)-1])
if paddingLength <= 0 || paddingLength > blockSize || paddingLength > len(data) {
return nil, errNavicatSecretDecryptFailed
}
for _, value := range data[len(data)-paddingLength:] {
if int(value) != paddingLength {
return nil, errNavicatSecretDecryptFailed
}
}
return data[:len(data)-paddingLength], nil
}
func isLikelyNavicatPlaintext(data []byte) bool {
if len(data) == 0 {
return true
}
if !utf8.Valid(data) {
return false
}
for _, r := range string(data) {
if unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t' {
return false
}
}
return true
}
func xorBytes(left, right []byte) []byte {
limit := len(left)
if len(right) < limit {
limit = len(right)
}
result := make([]byte, limit)
for index := 0; index < limit; index++ {
result[index] = left[index] ^ right[index]
}
return result
}
func bytesRepeat(value byte, count int) []byte {
data := make([]byte, count)
for index := range data {
data[index] = value
}
return data
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}

View File

@@ -0,0 +1,219 @@
package app
import (
"crypto/aes"
"encoding/hex"
"fmt"
"strings"
"testing"
)
func TestImportConnectionsPayloadNavicatNCXImportsConfigsAndSecrets(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
mysqlPassword := mustEncryptNavicatV1(t, "mysql-secret")
postgresPassword := mustEncryptNavicatV2(t, "pg-secret")
sshPassword := mustEncryptNavicatV1(t, "ssh-secret")
proxyPassword := mustEncryptNavicatV2(t, "proxy-secret")
raw := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<Connections>
<Connection ConnType="MYSQL" ConnectionName="Primary MySQL" Host="127.0.0.1" Port="3307" Database="demo" UserName="root" Password="%s" SavePassword="true" />
<Connection ConnType="POSTGRESQL" ConnectionName="Reporting PG" Host="pg.local" Port="5433" Database="reporting" UserName="analyst" Password="%s" SavePassword="true" SSL="true" SSL_PGSSLMode="REQUIRE" SSL_CACert="/etc/ssl/ca.pem" SSH="true" SSH_Host="jump.local" SSH_Port="2222" SSH_UserName="ops" SSH_Password="%s" SSH_SavePassword="true" HTTP_Proxy="true" HTTP_Proxy_Host="proxy.local" HTTP_Proxy_Port="8088" HTTP_Proxy_UserName="proxy-user" HTTP_Proxy_Password="%s" HTTP_Proxy_SavePassword="true" />
<Connection ConnType="SQLITE" ConnectionName="History DB" DatabaseFileName="C:\navicat\history.db" />
</Connections>`, mysqlPassword, postgresPassword, sshPassword, proxyPassword)
imported, err := app.ImportConnectionsPayload(raw, "")
if err != nil {
t.Fatalf("ImportConnectionsPayload returned error: %v", err)
}
if len(imported) != 3 {
t.Fatalf("expected 3 imported connections, got %d", len(imported))
}
mysqlConn := imported[0]
if mysqlConn.Name != "Primary MySQL" {
t.Fatalf("expected mysql connection name, got %q", mysqlConn.Name)
}
if mysqlConn.Config.Type != "mysql" {
t.Fatalf("expected mysql config type, got %q", mysqlConn.Config.Type)
}
if !mysqlConn.HasPrimaryPassword {
t.Fatal("expected mysql connection password flag")
}
resolvedMySQL, err := app.resolveConnectionSecrets(mysqlConn.Config)
if err != nil {
t.Fatalf("resolveConnectionSecrets for mysql returned error: %v", err)
}
if resolvedMySQL.Password != "mysql-secret" {
t.Fatalf("expected mysql password, got %q", resolvedMySQL.Password)
}
if resolvedMySQL.Host != "127.0.0.1" || resolvedMySQL.Port != 3307 || resolvedMySQL.Database != "demo" {
t.Fatalf("expected mysql host/port/database to survive import, got %#v", resolvedMySQL)
}
postgresConn := imported[1]
if postgresConn.Config.Type != "postgres" {
t.Fatalf("expected postgres config type, got %q", postgresConn.Config.Type)
}
if !postgresConn.HasPrimaryPassword || !postgresConn.HasSSHPassword || !postgresConn.HasProxyPassword {
t.Fatalf("expected postgres connection to keep primary/ssh/proxy secrets, got %#v", postgresConn)
}
if !postgresConn.Config.UseSSL || postgresConn.Config.SSLMode != "required" || postgresConn.Config.SSLCAPath != "/etc/ssl/ca.pem" {
t.Fatalf("expected postgres SSL settings to survive import, got %#v", postgresConn.Config)
}
if !postgresConn.Config.UseSSH || postgresConn.Config.SSH.Host != "jump.local" || postgresConn.Config.SSH.Port != 2222 || postgresConn.Config.SSH.User != "ops" {
t.Fatalf("expected postgres SSH settings to survive import, got %#v", postgresConn.Config.SSH)
}
if !postgresConn.Config.UseProxy || postgresConn.Config.Proxy.Type != "http" || postgresConn.Config.Proxy.Host != "proxy.local" || postgresConn.Config.Proxy.Port != 8088 || postgresConn.Config.Proxy.User != "proxy-user" {
t.Fatalf("expected postgres proxy settings to survive import, got %#v", postgresConn.Config.Proxy)
}
resolvedPostgres, err := app.resolveConnectionSecrets(postgresConn.Config)
if err != nil {
t.Fatalf("resolveConnectionSecrets for postgres returned error: %v", err)
}
if resolvedPostgres.Password != "pg-secret" {
t.Fatalf("expected postgres password, got %q", resolvedPostgres.Password)
}
if resolvedPostgres.SSH.Password != "ssh-secret" {
t.Fatalf("expected ssh password, got %q", resolvedPostgres.SSH.Password)
}
if resolvedPostgres.Proxy.Password != "proxy-secret" {
t.Fatalf("expected proxy password, got %q", resolvedPostgres.Proxy.Password)
}
sqliteConn := imported[2]
if sqliteConn.Config.Type != "sqlite" {
t.Fatalf("expected sqlite config type, got %q", sqliteConn.Config.Type)
}
if sqliteConn.Config.Host != `C:\navicat\history.db` || sqliteConn.Config.Database != `C:\navicat\history.db` {
t.Fatalf("expected sqlite file path to survive import, got %#v", sqliteConn.Config)
}
}
func TestImportConnectionsPayloadNavicatNCXRejectsUndecryptableSavedPassword(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
raw := `<?xml version="1.0" encoding="UTF-8"?>
<Connections>
<Connection ConnType="MYSQL" ConnectionName="Broken" Host="127.0.0.1" Port="3306" UserName="root" Password="FFFF" SavePassword="true" />
</Connections>`
_, err := app.ImportConnectionsPayload(raw, "")
if err == nil {
t.Fatal("expected invalid Navicat password import to fail")
}
if got := err.Error(); got == "" || !strings.Contains(got, "Navicat") && !strings.Contains(got, "密码") {
t.Fatalf("expected navicat password error, got %v", err)
}
}
func TestImportConnectionsPayloadNavicatNCXMapsOracleSIDAndRedisDB(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
oraclePassword := mustEncryptNavicatV2(t, "oracle-secret")
redisPassword := mustEncryptNavicatV1(t, "redis-secret")
raw := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<Connections>
<Connection ConnType="ORACLE" ConnectionName="Oracle SID" Host="oracle.local" Port="1521" Database="ORCL" OraServiceNameType="SID" UserName="system" Password="%s" SavePassword="true" />
<Connection ConnType="REDIS" ConnectionName="Redis Cache" Host="redis.local" Port="6379" Database="5" UserName="default" Password="%s" SavePassword="true" />
</Connections>`, oraclePassword, redisPassword)
imported, err := app.ImportConnectionsPayload(raw, "")
if err != nil {
t.Fatalf("ImportConnectionsPayload returned error: %v", err)
}
if len(imported) != 2 {
t.Fatalf("expected 2 imported connections, got %d", len(imported))
}
oracleConn := imported[0]
if oracleConn.Config.Type != "oracle" || oracleConn.Config.ConnectionParams != "SID=ORCL" {
t.Fatalf("expected oracle SID connection params, got %#v", oracleConn.Config)
}
resolvedOracle, err := app.resolveConnectionSecrets(oracleConn.Config)
if err != nil {
t.Fatalf("resolveConnectionSecrets for oracle returned error: %v", err)
}
if resolvedOracle.Password != "oracle-secret" {
t.Fatalf("expected oracle password, got %q", resolvedOracle.Password)
}
redisConn := imported[1]
if redisConn.Config.Type != "redis" || redisConn.Config.RedisDB != 5 {
t.Fatalf("expected redis db index to survive import, got %#v", redisConn.Config)
}
resolvedRedis, err := app.resolveConnectionSecrets(redisConn.Config)
if err != nil {
t.Fatalf("resolveConnectionSecrets for redis returned error: %v", err)
}
if resolvedRedis.Password != "redis-secret" {
t.Fatalf("expected redis password, got %q", resolvedRedis.Password)
}
}
func mustEncryptNavicatV1(t *testing.T, value string) string {
t.Helper()
cryptoV1, err := newNavicatCryptoV1()
if err != nil {
t.Fatalf("newNavicatCryptoV1 returned error: %v", err)
}
return cryptoV1.encrypt([]byte(value))
}
func mustEncryptNavicatV2(t *testing.T, value string) string {
t.Helper()
block, err := aes.NewCipher([]byte("libcckeylibcckey"))
if err != nil {
t.Fatalf("aes.NewCipher returned error: %v", err)
}
plaintext := applyPKCS7Padding([]byte(value), block.BlockSize())
currentVector := []byte("libcciv libcciv ")
ciphertext := make([]byte, len(plaintext))
for offset := 0; offset < len(plaintext); offset += block.BlockSize() {
blockInput := xorBytes(plaintext[offset:offset+block.BlockSize()], currentVector)
block.Encrypt(ciphertext[offset:offset+block.BlockSize()], blockInput)
currentVector = ciphertext[offset : offset+block.BlockSize()]
}
return strings.ToUpper(hex.EncodeToString(ciphertext))
}
func (c *navicatCryptoV1) encrypt(plaintext []byte) string {
blockSize := c.block.BlockSize()
currentVector := append([]byte(nil), c.initialVector...)
roundBytes := len(plaintext) / blockSize * blockSize
ciphertext := make([]byte, 0, len(plaintext))
encryptedBlock := make([]byte, blockSize)
for offset := 0; offset < roundBytes; offset += blockSize {
blockInput := xorBytes(plaintext[offset:offset+blockSize], currentVector)
c.block.Encrypt(encryptedBlock, blockInput)
ciphertext = append(ciphertext, encryptedBlock...)
currentVector = xorBytes(currentVector, encryptedBlock)
}
if leftover := len(plaintext) - roundBytes; leftover > 0 {
stream := make([]byte, blockSize)
c.block.Encrypt(stream, currentVector)
ciphertext = append(ciphertext, xorBytes(plaintext[roundBytes:], stream[:leftover])...)
}
return strings.ToUpper(hex.EncodeToString(ciphertext))
}
func applyPKCS7Padding(data []byte, blockSize int) []byte {
paddingLength := blockSize - (len(data) % blockSize)
if paddingLength == 0 {
paddingLength = blockSize
}
padded := make([]byte, 0, len(data)+paddingLength)
padded = append(padded, data...)
for i := 0; i < paddingLength; i++ {
padded = append(padded, byte(paddingLength))
}
return padded
}