diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index f120f06..c5adfe7 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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} 个连接`);
diff --git a/frontend/src/utils/connectionExport.test.ts b/frontend/src/utils/connectionExport.test.ts
index d18541a..110535a 100644
--- a/frontend/src/utils/connectionExport.test.ts
+++ b/frontend/src/utils/connectionExport.test.ts
@@ -88,6 +88,13 @@ describe('connectionExport', () => {
]))).toBe('legacy-json');
});
+ it('detects Navicat NCX xml exports', () => {
+ expect(detectConnectionImportKind(`
+
+
+`)).toBe('navicat-ncx');
+ });
+
it('returns invalid for malformed or unsupported content', () => {
expect(detectConnectionImportKind('{not-json}')).toBe('invalid');
expect(detectConnectionImportKind(JSON.stringify({
diff --git a/frontend/src/utils/connectionExport.ts b/frontend/src/utils/connectionExport.ts
index 2a790c3..a54ce02 100644
--- a/frontend/src/utils/connectionExport.ts
+++ b/frontend/src/utils/connectionExport.ts
@@ -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(' (
+ raw.includes(' {
if (typeof raw === 'string' && isMySQLWorkbenchXML(raw)) {
return 'mysql-workbench-xml';
}
+ if (typeof raw === 'string' && isNavicatNCX(raw)) {
+ return 'navicat-ncx';
+ }
const parsed = parseConnectionImportRaw(raw);
diff --git a/internal/app/connection_package_transfer.go b/internal/app/connection_package_transfer.go
index 18a07d0..36e38e2 100644
--- a/internal/app/connection_package_transfer.go
+++ b/internal/app/connection_package_transfer.go
@@ -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
diff --git a/internal/app/navicat_ncx_import.go b/internal/app/navicat_ncx_import.go
new file mode 100644
index 0000000..7935eb5
--- /dev/null
+++ b/internal/app/navicat_ncx_import.go
@@ -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, "= '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 ""
+}
diff --git a/internal/app/navicat_ncx_import_test.go b/internal/app/navicat_ncx_import_test.go
new file mode 100644
index 0000000..eddea35
--- /dev/null
+++ b/internal/app/navicat_ncx_import_test.go
@@ -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(`
+
+
+
+
+`, 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 := `
+
+
+`
+
+ _, 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(`
+
+
+
+`, 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
+}