🐛 fix(security): 修复 macOS 无法打开应用及三平台依赖系统钥匙串的问题

- 密文存储:新增 dailysecret 本地存储引擎,连接/代理/AI 密钥不再依赖系统钥匙串
- 启动迁移:自动将已有钥匙串密文迁移到本地 JSON,用户无感知
- WebKit 迁移:从旧版 Wails WebKit LocalStorage 中恢复连接与代理数据
- DMG 修复:移除 --sandbox-safe 避免扩展属性污染签名,新增 xattr 清理与签名校验
- 安全适配:钥匙串不可用时标记完成而非回滚,消除无钥匙串环境下的阻塞
- 出口脱敏:所有连接/代理 API 返回前统一 sanitize 防止密文泄漏
This commit is contained in:
Syngnat
2026-04-13 12:40:25 +08:00
parent 604aaad69d
commit c7cf9526de
36 changed files with 2097 additions and 497 deletions

View File

@@ -55,17 +55,17 @@ type queryContext struct {
// App struct
type App struct {
ctx context.Context
startedAt time.Time
dbCache map[string]cachedDatabase // Cache for DB connections
ctx context.Context
startedAt time.Time
dbCache map[string]cachedDatabase // Cache for DB connections
connectFailures map[string]cachedConnectFailure
mu sync.RWMutex // Mutex for cache access
updateMu sync.Mutex
updateState updateState
queryMu sync.RWMutex
configDir string
secretStore secretstore.SecretStore
runningQueries map[string]queryContext // queryID -> cancelFunc and start time
mu sync.RWMutex // Mutex for cache access
updateMu sync.Mutex
updateState updateState
queryMu sync.RWMutex
configDir string
secretStore secretstore.SecretStore
runningQueries map[string]queryContext // queryID -> cancelFunc and start time
}
// NewApp creates a new App application struct
@@ -78,11 +78,11 @@ func NewAppWithSecretStore(store secretstore.SecretStore) *App {
store = secretstore.NewUnavailableStore("secret store unavailable")
}
return &App{
dbCache: make(map[string]cachedDatabase),
dbCache: make(map[string]cachedDatabase),
connectFailures: make(map[string]cachedConnectFailure),
runningQueries: make(map[string]queryContext),
configDir: resolveAppConfigDir(),
secretStore: store,
runningQueries: make(map[string]queryContext),
configDir: resolveAppConfigDir(),
secretStore: store,
}
}
@@ -101,7 +101,13 @@ func (a *App) startup(ctx context.Context) {
}
db.SetExternalDriverDownloadDirectory(appdata.DriverRoot(a.configDir))
logger.Init()
if err := migrateDailySecretsIfNeeded(a); err != nil {
logger.Warnf("迁移日常密文失败:%v", err)
}
a.loadPersistedGlobalProxy()
if err := migrateLegacyWebKitStorageIfNeeded(a); err != nil {
logger.Warnf("迁移旧 WebKit 连接存储失败:%v", err)
}
if shouldInstallMacNativeWindowDiagnostics() {
installMacNativeWindowDiagnostics(logger.Path())
}

View File

@@ -21,7 +21,7 @@ func newConnectionPackageItem(view connection.SavedConnectionView, bundle connec
IncludeRedisDatabases: cloneIntSlice(view.IncludeRedisDatabases),
IconType: view.IconType,
IconColor: view.IconColor,
Config: view.Config,
Config: stripConnectionSecretFields(view.Config),
Secrets: bundle,
}
}
@@ -229,7 +229,11 @@ func (a *App) ImportConnectionsPayload(raw string, password string) ([]connectio
if err != nil {
return nil, err
}
return a.importConnectionPackagePayload(payload)
views, err := a.importConnectionPackagePayload(payload)
if err != nil {
return nil, err
}
return sanitizeSavedConnectionViews(views), nil
}
if isConnectionPackageV2Protected(trimmed) {
@@ -241,7 +245,11 @@ func (a *App) ImportConnectionsPayload(raw string, password string) ([]connectio
if err != nil {
return nil, err
}
return a.importConnectionPackagePayload(payload)
views, err := a.importConnectionPackagePayload(payload)
if err != nil {
return nil, err
}
return sanitizeSavedConnectionViews(views), nil
}
if isConnectionPackageEnvelope(trimmed) {
@@ -253,7 +261,11 @@ func (a *App) ImportConnectionsPayload(raw string, password string) ([]connectio
if err != nil {
return nil, err
}
return a.importConnectionPackagePayload(payload)
views, err := a.importConnectionPackagePayload(payload)
if err != nil {
return nil, err
}
return sanitizeSavedConnectionViews(views), nil
}
var legacy []connection.LegacySavedConnection

View File

@@ -297,6 +297,8 @@ func TestImportConnectionPackagePayloadLatestEntryWinsForSameID(t *testing.T) {
}
func TestImportConnectionsPayloadLegacyJSONRollsBackOnSaveFailure(t *testing.T) {
withTestGOOS(t, "linux")
failRef, err := secretstore.BuildRef(savedConnectionSecretKind, "legacy-2")
if err != nil {
t.Fatalf("BuildRef returned error: %v", err)
@@ -352,33 +354,33 @@ func TestImportConnectionsPayloadLegacyJSONRollsBackOnSaveFailure(t *testing.T)
}
imported, err := app.ImportConnectionsPayload(string(raw), "ignored")
if err == nil {
t.Fatal("expected ImportConnectionsPayload to return error")
if err != nil {
t.Fatalf("expected ImportConnectionsPayload to succeed without secret store, got %v", err)
}
if imported != nil {
t.Fatalf("expected no imported results after rollback, got %#v", imported)
if len(imported) != 2 {
t.Fatalf("expected 2 imported results, got %#v", imported)
}
saved, err := app.GetSavedConnections()
if err != nil {
t.Fatalf("GetSavedConnections returned error: %v", err)
}
if len(saved) != 1 {
t.Fatalf("expected rollback to restore exactly 1 legacy connection, got %d", len(saved))
if len(saved) != 2 {
t.Fatalf("expected import to keep 2 legacy connections, got %d", len(saved))
}
if saved[0].ID != "legacy-1" || saved[0].Name != "Existing Legacy" {
t.Fatalf("expected rollback to restore original legacy metadata, got %#v", saved[0])
if saved[0].ID != "legacy-1" || saved[0].Name != "Imported Existing Legacy" {
t.Fatalf("expected updated legacy metadata, got %#v", saved[0])
}
if saved[0].Config.Host != "db.old.local" {
t.Fatalf("expected rollback to restore original legacy host, got %q", saved[0].Config.Host)
if saved[0].Config.Host != "db.new.local" {
t.Fatalf("expected import to update legacy host, got %q", saved[0].Config.Host)
}
resolved, err := app.resolveConnectionSecrets(saved[0].Config)
if err != nil {
t.Fatalf("resolveConnectionSecrets returned error: %v", err)
}
if resolved.Password != "old-primary" {
t.Fatalf("expected rollback to restore original legacy password, got %q", resolved.Password)
if resolved.Password != "" {
t.Fatalf("expected legacy import without password to clear stored password, got %q", resolved.Password)
}
if _, err := store.Get(failRef); !os.IsNotExist(err) {
@@ -387,6 +389,8 @@ func TestImportConnectionsPayloadLegacyJSONRollsBackOnSaveFailure(t *testing.T)
}
func TestImportLegacyConnectionsRollbackRemovesGeneratedSecretRefs(t *testing.T) {
withTestGOOS(t, "linux")
failRef, err := secretstore.BuildRef(savedConnectionSecretKind, "legacy-2")
if err != nil {
t.Fatalf("BuildRef returned error: %v", err)
@@ -420,19 +424,19 @@ func TestImportLegacyConnectionsRollbackRemovesGeneratedSecretRefs(t *testing.T)
},
},
})
if err == nil {
t.Fatal("expected ImportLegacyConnections to return error")
if err != nil {
t.Fatalf("expected ImportLegacyConnections to succeed without secret store, got %v", err)
}
if imported != nil {
t.Fatalf("expected no imported results after rollback, got %#v", imported)
if len(imported) != 2 {
t.Fatalf("expected 2 imported results after import, got %#v", imported)
}
saved, err := app.GetSavedConnections()
if err != nil {
t.Fatalf("GetSavedConnections returned error: %v", err)
}
if len(saved) != 0 {
t.Fatalf("expected rollback to remove generated-id connection, got %d saved connections", len(saved))
if len(saved) != 2 {
t.Fatalf("expected imported connections to be persisted, got %d saved connections", len(saved))
}
if got := len(store.base.items); got != 0 {
@@ -444,6 +448,8 @@ func TestImportLegacyConnectionsRollbackRemovesGeneratedSecretRefs(t *testing.T)
}
func TestImportConnectionPackagePayloadRollsBackOnSaveFailure(t *testing.T) {
withTestGOOS(t, "linux")
failRef, err := secretstore.BuildRef(savedConnectionSecretKind, "conn-2")
if err != nil {
t.Fatalf("BuildRef returned error: %v", err)
@@ -497,33 +503,33 @@ func TestImportConnectionPackagePayloadRollsBackOnSaveFailure(t *testing.T) {
},
},
})
if err == nil {
t.Fatal("expected importConnectionPackagePayload to return error")
if err != nil {
t.Fatalf("expected importConnectionPackagePayload to succeed without secret store, got %v", err)
}
if imported != nil {
t.Fatalf("expected no imported results after rollback, got %#v", imported)
if len(imported) != 2 {
t.Fatalf("expected 2 imported results after import, got %#v", imported)
}
saved, err := app.GetSavedConnections()
if err != nil {
t.Fatalf("GetSavedConnections returned error: %v", err)
}
if len(saved) != 1 {
t.Fatalf("expected rollback to restore exactly 1 connection, got %d", len(saved))
if len(saved) != 2 {
t.Fatalf("expected import to keep 2 connections, got %d", len(saved))
}
if saved[0].ID != "conn-1" || saved[0].Name != "Existing" {
t.Fatalf("expected rollback to restore original connection metadata, got %#v", saved[0])
if saved[0].ID != "conn-1" || saved[0].Name != "Imported Existing" {
t.Fatalf("expected imported connection metadata, got %#v", saved[0])
}
if saved[0].Config.Host != "db.old.local" {
t.Fatalf("expected rollback to restore original host, got %q", saved[0].Config.Host)
if saved[0].Config.Host != "db.new.local" {
t.Fatalf("expected import to update host, got %q", saved[0].Config.Host)
}
resolved, err := app.resolveConnectionSecrets(saved[0].Config)
if err != nil {
t.Fatalf("resolveConnectionSecrets returned error: %v", err)
}
if resolved.Password != "old-primary" {
t.Fatalf("expected rollback to restore original primary password, got %q", resolved.Password)
if resolved.Password != "new-primary" {
t.Fatalf("expected import to update primary password, got %q", resolved.Password)
}
if _, err := store.Get(failRef); !os.IsNotExist(err) {

View File

@@ -103,6 +103,8 @@ func normalizeConnectionSecretResolutionError(config connection.ConnectionConfig
return fmt.Errorf("未找到已保存连接,可能已被删除,请刷新后重试")
}
return fmt.Errorf("未找到当前连接对应的已保存密文,请重新填写密码并保存后再试")
case errors.Is(err, os.ErrNotExist):
return fmt.Errorf("未找到当前连接对应的已保存密文,请重新填写密码并保存后再试")
case strings.Contains(lower, "secret store unavailable"):
return fmt.Errorf("系统密文存储当前不可用,请检查系统钥匙串或凭据管理器后再试")
default:

View File

@@ -42,6 +42,44 @@ func TestResolveConnectionConfigByIDLoadsSecretsFromStore(t *testing.T) {
}
}
func TestResolveConnectionSecretsOnDarwinUsesInlineSavedSecrets(t *testing.T) {
app := NewAppWithSecretStore(failOnUseSecretStore{})
app.configDir = t.TempDir()
if _, err := app.SaveConnection(connection.SavedConnectionInput{
ID: "conn-darwin-inline",
Name: "Primary",
Config: connection.ConnectionConfig{
ID: "conn-darwin-inline",
Type: "postgres",
Host: "db.local",
Port: 5432,
User: "postgres",
Password: "postgres-secret",
DSN: "postgres://user:pass@db.local/app",
},
}); err != nil {
t.Fatalf("SaveConnection returned error: %v", err)
}
resolved, err := app.resolveConnectionSecrets(connection.ConnectionConfig{
ID: "conn-darwin-inline",
Type: "postgres",
Host: "db.local",
Port: 5432,
User: "postgres",
})
if err != nil {
t.Fatalf("resolveConnectionSecrets returned error: %v", err)
}
if resolved.Password != "postgres-secret" {
t.Fatalf("expected daily-stored password to be restored, got %q", resolved.Password)
}
if resolved.DSN != "postgres://user:pass@db.local/app" {
t.Fatalf("expected daily-stored DSN to be restored, got %q", resolved.DSN)
}
}
func TestResolveConnectionSecretsReturnsFriendlyMessageWhenSavedSecretSourceIsMissing(t *testing.T) {
store := newFakeAppSecretStore()
app := NewAppWithSecretStore(store)
@@ -90,6 +128,8 @@ func TestResolveConnectionSecretsFallsBackToInlineSecretsWhenSavedConnectionIsMi
}
func TestResolveConnectionSecretsFallsBackToInlineSecretsWhenSavedSecretBundleIsMissing(t *testing.T) {
withTestGOOS(t, "linux")
store := newFakeAppSecretStore()
app := NewAppWithSecretStore(store)
app.configDir = t.TempDir()
@@ -110,11 +150,11 @@ func TestResolveConnectionSecretsFallsBackToInlineSecretsWhenSavedSecretBundleIs
if err != nil {
t.Fatalf("SaveConnection returned error: %v", err)
}
if view.SecretRef == "" {
t.Fatal("expected saved connection to allocate a secret ref")
if view.SecretRef != "" {
t.Fatalf("expected saved connection to avoid secret refs, got %q", view.SecretRef)
}
if err := store.Delete(view.SecretRef); err != nil {
t.Fatalf("Delete returned error: %v", err)
if err := app.dailySecretStore().DeleteConnection("conn-inline-fallback"); err != nil {
t.Fatalf("DeleteConnection returned error: %v", err)
}
resolved, err := app.resolveConnectionSecrets(connection.ConnectionConfig{

View File

@@ -0,0 +1,223 @@
package app
import (
"os"
"strings"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/logger"
"GoNavi-Wails/internal/secretstore"
)
func migrateDailySecretsIfNeeded(a *App) error {
return migrateDailySecretsIfNeededWithHome(a, os.UserHomeDir)
}
func migrateDarwinDailySecretsIfNeeded(a *App) error {
return migrateDailySecretsIfNeeded(a)
}
func migrateDailySecretsIfNeededWithHome(a *App, resolveHomeDir func() (string, error)) error {
if a == nil {
return nil
}
var legacy legacyWebKitVisibleConfig
if resolveHomeDir != nil {
homeDir, err := resolveHomeDir()
if err != nil {
return err
}
legacyConfig, _, err := findLegacyWebKitVisibleConfig(homeDir)
if err != nil {
return err
}
legacy = legacyConfig
}
repo := a.savedConnectionRepository()
if err := migrateSavedConnectionSecrets(repo, legacy); err != nil {
return err
}
return migrateGlobalProxySecret(a, legacy)
}
func migrateDarwinDailySecretsIfNeededWithHome(a *App, resolveHomeDir func() (string, error)) error {
return migrateDailySecretsIfNeededWithHome(a, resolveHomeDir)
}
func migrateSavedConnectionSecrets(repo *savedConnectionRepository, legacy legacyWebKitVisibleConfig) error {
if repo == nil {
return nil
}
items, err := repo.load()
if err != nil {
return err
}
changed := false
for index, item := range items {
bundle, found, err := repo.resolveMigrationConnectionBundle(item, legacy)
if err != nil {
return err
}
if found && bundle.hasAny() {
if err := repo.saveSecretBundle(item.ID, bundle); err != nil {
return err
}
normalized := item
normalized.Config = stripConnectionSecretFields(normalized.Config)
normalized.SecretRef = ""
applyConnectionBundleFlags(&normalized, bundle)
items[index] = normalized
changed = true
continue
}
inline := extractConnectionSecretBundle(item.Config)
if !inline.hasAny() && !savedConnectionViewHasSecrets(item) && strings.TrimSpace(item.SecretRef) == "" {
continue
}
if err := repo.deleteSecretBundle(item.ID); err != nil {
return err
}
item.Config = stripConnectionSecretFields(item.Config)
item.SecretRef = ""
applyConnectionBundleFlags(&item, connectionSecretBundle{})
items[index] = item
changed = true
logger.Warnf("日常连接密文未回填:连接=%s已停用旧系统密文引用请重新保存连接密码", strings.TrimSpace(item.ID))
}
if changed {
return repo.saveAll(items)
}
return nil
}
func (r *savedConnectionRepository) resolveMigrationConnectionBundle(view connection.SavedConnectionView, legacy legacyWebKitVisibleConfig) (connectionSecretBundle, bool, error) {
inline := extractConnectionSecretBundle(view.Config)
if inline.hasAny() {
return inline, true, nil
}
stored, ok, err := r.dailySecrets().GetConnection(view.ID)
if err != nil {
return connectionSecretBundle{}, false, err
}
if ok {
return fromDailyConnectionBundle(stored), true, nil
}
legacyBundle := findLegacyConnectionSecretBundle(legacy.Connections, view.ID)
if legacyBundle.hasAny() {
return legacyBundle, true, nil
}
if !shouldReadLegacySecretStoreForDailySecrets() {
return connectionSecretBundle{}, false, nil
}
if strings.TrimSpace(view.SecretRef) == "" {
return connectionSecretBundle{}, false, nil
}
bundle, err := r.loadSecretBundleFromStore(view)
if err == nil {
return bundle, true, nil
}
if os.IsNotExist(err) || secretstore.IsUnavailable(err) {
return connectionSecretBundle{}, false, nil
}
return connectionSecretBundle{}, false, err
}
func migrateGlobalProxySecret(a *App, legacy legacyWebKitVisibleConfig) error {
view, err := a.loadStoredGlobalProxyView()
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
bundle, found, err := a.resolveMigrationGlobalProxyBundle(view, legacy)
if err != nil {
return err
}
if found && strings.TrimSpace(bundle.Password) != "" {
if err := a.dailySecretStore().PutGlobalProxy(toDailyGlobalProxyBundle(bundle)); err != nil {
return err
}
normalized := view
normalized.Password = ""
normalized.SecretRef = ""
normalized.HasPassword = true
if normalized != view {
return a.persistGlobalProxyView(normalized)
}
return nil
}
inline := extractGlobalProxySecretBundle(view)
if !view.HasPassword && strings.TrimSpace(view.SecretRef) == "" && strings.TrimSpace(inline.Password) == "" {
return nil
}
if err := a.dailySecretStore().DeleteGlobalProxy(); err != nil {
return err
}
view.Password = ""
view.SecretRef = ""
view.HasPassword = false
logger.Warnf("日常全局代理密文未回填,已停用旧系统密文引用,如需继续使用请重新保存代理密码")
return a.persistGlobalProxyView(view)
}
func (a *App) resolveMigrationGlobalProxyBundle(view connection.GlobalProxyView, legacy legacyWebKitVisibleConfig) (globalProxySecretBundle, bool, error) {
inline := extractGlobalProxySecretBundle(view)
if strings.TrimSpace(inline.Password) != "" {
return inline, true, nil
}
stored, ok, err := a.dailySecretStore().GetGlobalProxy()
if err != nil {
return globalProxySecretBundle{}, false, err
}
if ok {
return fromDailyGlobalProxyBundle(stored), true, nil
}
if legacy.GlobalProxy != nil && strings.TrimSpace(legacy.GlobalProxy.Password) != "" {
return globalProxySecretBundle{Password: legacy.GlobalProxy.Password}, true, nil
}
if !shouldReadLegacySecretStoreForDailySecrets() {
return globalProxySecretBundle{}, false, nil
}
if strings.TrimSpace(view.SecretRef) == "" {
return globalProxySecretBundle{}, false, nil
}
bundle, err := a.loadGlobalProxySecretBundleFromStore(view)
if err == nil {
return bundle, true, nil
}
if os.IsNotExist(err) || secretstore.IsUnavailable(err) {
return globalProxySecretBundle{}, false, nil
}
return globalProxySecretBundle{}, false, err
}
func findLegacyConnectionSecretBundle(items []connection.LegacySavedConnection, id string) connectionSecretBundle {
targetID := strings.TrimSpace(id)
if targetID == "" {
return connectionSecretBundle{}
}
for _, item := range items {
if strings.TrimSpace(item.ID) != targetID {
continue
}
return extractConnectionSecretBundle(item.Config)
}
return connectionSecretBundle{}
}

View File

@@ -0,0 +1,107 @@
package app
import (
stdRuntime "runtime"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/dailysecret"
)
var runtimeGOOS = func() string {
return stdRuntime.GOOS
}
func extractConnectionSecretBundle(config connection.ConnectionConfig) connectionSecretBundle {
return connectionSecretBundle{
Password: config.Password,
SSHPassword: config.SSH.Password,
ProxyPassword: config.Proxy.Password,
HTTPTunnelPassword: config.HTTPTunnel.Password,
MySQLReplicaPassword: config.MySQLReplicaPassword,
MongoReplicaPassword: config.MongoReplicaPassword,
OpaqueURI: config.URI,
OpaqueDSN: config.DSN,
}
}
func toDailyConnectionBundle(bundle connectionSecretBundle) dailysecret.ConnectionBundle {
return dailysecret.ConnectionBundle{
Password: bundle.Password,
SSHPassword: bundle.SSHPassword,
ProxyPassword: bundle.ProxyPassword,
HTTPTunnelPassword: bundle.HTTPTunnelPassword,
MySQLReplicaPassword: bundle.MySQLReplicaPassword,
MongoReplicaPassword: bundle.MongoReplicaPassword,
OpaqueURI: bundle.OpaqueURI,
OpaqueDSN: bundle.OpaqueDSN,
}
}
func fromDailyConnectionBundle(bundle dailysecret.ConnectionBundle) connectionSecretBundle {
return connectionSecretBundle{
Password: bundle.Password,
SSHPassword: bundle.SSHPassword,
ProxyPassword: bundle.ProxyPassword,
HTTPTunnelPassword: bundle.HTTPTunnelPassword,
MySQLReplicaPassword: bundle.MySQLReplicaPassword,
MongoReplicaPassword: bundle.MongoReplicaPassword,
OpaqueURI: bundle.OpaqueURI,
OpaqueDSN: bundle.OpaqueDSN,
}
}
func stripConnectionSecretFields(config connection.ConnectionConfig) connection.ConnectionConfig {
stripped := config
stripped.Password = ""
stripped.SSH.Password = ""
stripped.Proxy.Password = ""
stripped.HTTPTunnel.Password = ""
stripped.MySQLReplicaPassword = ""
stripped.MongoReplicaPassword = ""
stripped.URI = ""
stripped.DSN = ""
return stripped
}
func sanitizeSavedConnectionView(view connection.SavedConnectionView) connection.SavedConnectionView {
view.Config = stripConnectionSecretFields(view.Config)
return view
}
func sanitizeSavedConnectionViews(items []connection.SavedConnectionView) []connection.SavedConnectionView {
if len(items) == 0 {
return items
}
result := make([]connection.SavedConnectionView, 0, len(items))
for _, item := range items {
result = append(result, sanitizeSavedConnectionView(item))
}
return result
}
func extractGlobalProxySecretBundle(view connection.GlobalProxyView) globalProxySecretBundle {
return globalProxySecretBundle{
Password: view.Password,
}
}
func toDailyGlobalProxyBundle(bundle globalProxySecretBundle) dailysecret.GlobalProxyBundle {
return dailysecret.GlobalProxyBundle{Password: bundle.Password}
}
func fromDailyGlobalProxyBundle(bundle dailysecret.GlobalProxyBundle) globalProxySecretBundle {
return globalProxySecretBundle{Password: bundle.Password}
}
func sanitizeGlobalProxyView(view connection.GlobalProxyView) connection.GlobalProxyView {
view.Password = ""
return view
}
func shouldReadLegacySecretStoreForDailySecrets() bool {
return runtimeGOOS() != "darwin"
}
func (a *App) dailySecretStore() *dailysecret.Store {
return dailysecret.NewStore(a.configDir)
}

View File

@@ -0,0 +1,210 @@
package app
import (
"testing"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/secretstore"
)
func TestMigrateDarwinDailySecretsIfNeededMovesConnectionSecretsInline(t *testing.T) {
withTestGOOS(t, "darwin")
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("blocked"))
app.configDir = t.TempDir()
homeDir := t.TempDir()
writeLegacyWebKitStorage(t, homeDir, "com.wails.GoNavi", legacyWebKitStoragePayload{
Connections: []connection.LegacySavedConnection{
{
ID: "conn-legacy",
Name: "Legacy",
Config: connection.ConnectionConfig{
ID: "conn-legacy",
Type: "postgres",
Host: "db.local",
Port: 5432,
User: "postgres",
Password: "postgres-secret",
DSN: "postgres://user:pass@db.local/app",
},
},
},
})
repo := app.savedConnectionRepository()
if err := repo.saveAll([]connection.SavedConnectionView{
{
ID: "conn-legacy",
Name: "Legacy",
Config: connection.ConnectionConfig{
ID: "conn-legacy",
Type: "postgres",
Host: "db.local",
Port: 5432,
User: "postgres",
},
SecretRef: "oskeyring://gonavi/connection/conn-legacy",
HasPrimaryPassword: true,
HasOpaqueDSN: true,
},
}); err != nil {
t.Fatalf("saveAll returned error: %v", err)
}
if err := migrateDarwinDailySecretsIfNeededWithHome(app, func() (string, error) {
return homeDir, nil
}); err != nil {
t.Fatalf("migrateDarwinDailySecretsIfNeededWithHome returned error: %v", err)
}
raw, err := repo.Find("conn-legacy")
if err != nil {
t.Fatalf("Find returned error: %v", err)
}
if raw.Config.Password != "" {
t.Fatalf("expected migrated connection metadata to stay secretless, got %q", raw.Config.Password)
}
if raw.Config.DSN != "" {
t.Fatalf("expected migrated connection metadata to stay secretless, got %q", raw.Config.DSN)
}
if raw.SecretRef != "" {
t.Fatalf("expected migrated connection to clear SecretRef, got %q", raw.SecretRef)
}
stored, ok, err := app.dailySecretStore().GetConnection("conn-legacy")
if err != nil {
t.Fatalf("GetConnection returned error: %v", err)
}
if !ok {
t.Fatal("expected migrated connection secret in daily secret store")
}
if stored.Password != "postgres-secret" || stored.OpaqueDSN != "postgres://user:pass@db.local/app" {
t.Fatalf("unexpected migrated bundle: %#v", stored)
}
}
func TestMigrateDarwinDailySecretsIfNeededMovesGlobalProxyPasswordInline(t *testing.T) {
withTestGOOS(t, "darwin")
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("blocked"))
app.configDir = t.TempDir()
homeDir := t.TempDir()
writeLegacyWebKitStorage(t, homeDir, "com.wails.GoNavi", legacyWebKitStoragePayload{
GlobalProxy: &connection.LegacyGlobalProxyInput{
Enabled: true,
Type: "http",
Host: "127.0.0.1",
Port: 8080,
User: "ops",
Password: "proxy-secret",
},
})
if err := app.persistGlobalProxyView(connection.GlobalProxyView{
Enabled: true,
Type: "http",
Host: "127.0.0.1",
Port: 8080,
User: "ops",
SecretRef: "oskeyring://gonavi/global-proxy/default",
HasPassword: true,
}); err != nil {
t.Fatalf("persistGlobalProxyView returned error: %v", err)
}
if err := migrateDarwinDailySecretsIfNeededWithHome(app, func() (string, error) {
return homeDir, nil
}); err != nil {
t.Fatalf("migrateDarwinDailySecretsIfNeededWithHome returned error: %v", err)
}
stored, err := app.loadStoredGlobalProxyView()
if err != nil {
t.Fatalf("loadStoredGlobalProxyView returned error: %v", err)
}
if stored.Password != "" {
t.Fatalf("expected migrated global proxy metadata to stay secretless, got %q", stored.Password)
}
if stored.SecretRef != "" {
t.Fatalf("expected migrated global proxy to clear SecretRef, got %q", stored.SecretRef)
}
secret, ok, err := app.dailySecretStore().GetGlobalProxy()
if err != nil {
t.Fatalf("GetGlobalProxy returned error: %v", err)
}
if !ok || secret.Password != "proxy-secret" {
t.Fatalf("unexpected migrated global proxy secret: %#v ok=%v", secret, ok)
}
}
func TestMigrateDarwinDailySecretsIfNeededClearsLegacyRefsWhenNoWebKitSecretAvailable(t *testing.T) {
withTestGOOS(t, "darwin")
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("blocked"))
app.configDir = t.TempDir()
homeDir := t.TempDir()
repo := app.savedConnectionRepository()
if err := repo.saveAll([]connection.SavedConnectionView{
{
ID: "conn-missing",
Name: "Missing",
Config: connection.ConnectionConfig{
ID: "conn-missing",
Type: "postgres",
Host: "db.local",
Port: 5432,
User: "postgres",
},
SecretRef: "oskeyring://gonavi/connection/conn-missing",
HasPrimaryPassword: true,
},
}); err != nil {
t.Fatalf("saveAll returned error: %v", err)
}
if err := app.persistGlobalProxyView(connection.GlobalProxyView{
Enabled: true,
Type: "http",
Host: "127.0.0.1",
Port: 8080,
User: "ops",
SecretRef: "oskeyring://gonavi/global-proxy/default",
HasPassword: true,
}); err != nil {
t.Fatalf("persistGlobalProxyView returned error: %v", err)
}
if err := migrateDarwinDailySecretsIfNeededWithHome(app, func() (string, error) {
return homeDir, nil
}); err != nil {
t.Fatalf("migrateDarwinDailySecretsIfNeededWithHome returned error: %v", err)
}
raw, err := repo.Find("conn-missing")
if err != nil {
t.Fatalf("Find returned error: %v", err)
}
if raw.SecretRef != "" || raw.HasPrimaryPassword {
t.Fatalf("expected missing legacy secret ref to be cleared, got %#v", raw)
}
stored, err := app.loadStoredGlobalProxyView()
if err != nil {
t.Fatalf("loadStoredGlobalProxyView returned error: %v", err)
}
if stored.SecretRef != "" || stored.HasPassword {
t.Fatalf("expected missing legacy proxy secret ref to be cleared, got %#v", stored)
}
if _, ok, err := app.dailySecretStore().GetConnection("conn-missing"); err != nil {
t.Fatalf("GetConnection returned error: %v", err)
} else if ok {
t.Fatal("expected no migrated connection secret when WebKit data is missing")
}
if _, ok, err := app.dailySecretStore().GetGlobalProxy(); err != nil {
t.Fatalf("GetGlobalProxy returned error: %v", err)
} else if ok {
t.Fatal("expected no migrated global proxy secret when WebKit data is missing")
}
}

View File

@@ -53,14 +53,11 @@ func (a *App) saveGlobalProxy(input connection.SaveGlobalProxyInput) (connection
return connection.GlobalProxyView{}, loadErr
}
bundle = existingBundle
view.SecretRef = existing.SecretRef
}
if !view.Enabled {
if strings.TrimSpace(existing.SecretRef) != "" && a.secretStore != nil {
if deleteErr := a.secretStore.Delete(existing.SecretRef); deleteErr != nil {
return connection.GlobalProxyView{}, deleteErr
}
if deleteErr := a.dailySecretStore().DeleteGlobalProxy(); deleteErr != nil {
return connection.GlobalProxyView{}, deleteErr
}
view = connection.GlobalProxyView{Enabled: false}
if err := a.persistGlobalProxyView(view); err != nil {
@@ -73,21 +70,18 @@ func (a *App) saveGlobalProxy(input connection.SaveGlobalProxyInput) (connection
}
if strings.TrimSpace(bundle.Password) != "" {
ref, storeErr := a.storeGlobalProxySecret(view.SecretRef, bundle)
if storeErr != nil {
if storeErr := a.dailySecretStore().PutGlobalProxy(toDailyGlobalProxyBundle(bundle)); storeErr != nil {
return connection.GlobalProxyView{}, storeErr
}
view.SecretRef = ref
view.HasPassword = true
} else {
if strings.TrimSpace(existing.SecretRef) != "" && a.secretStore != nil {
if deleteErr := a.secretStore.Delete(existing.SecretRef); deleteErr != nil {
return connection.GlobalProxyView{}, deleteErr
}
if deleteErr := a.dailySecretStore().DeleteGlobalProxy(); deleteErr != nil {
return connection.GlobalProxyView{}, deleteErr
}
view.SecretRef = ""
view.HasPassword = false
}
view.SecretRef = ""
view.Password = ""
if err := a.persistGlobalProxyView(view); err != nil {
return connection.GlobalProxyView{}, err
@@ -101,8 +95,7 @@ func (a *App) saveGlobalProxy(input connection.SaveGlobalProxyInput) (connection
}); err != nil {
return connection.GlobalProxyView{}, err
}
view.Password = ""
return view, nil
return sanitizeGlobalProxyView(view), nil
}
func (a *App) persistGlobalProxyView(view connection.GlobalProxyView) error {
@@ -129,9 +122,24 @@ func (a *App) loadStoredGlobalProxyView() (connection.GlobalProxyView, error) {
}
func (a *App) loadGlobalProxySecretBundle(view connection.GlobalProxyView) (globalProxySecretBundle, error) {
inline := extractGlobalProxySecretBundle(view)
if strings.TrimSpace(inline.Password) != "" {
return inline, nil
}
if !view.HasPassword {
return globalProxySecretBundle{}, nil
}
bundle, ok, err := a.dailySecretStore().GetGlobalProxy()
if err != nil {
return globalProxySecretBundle{}, err
}
if ok {
return fromDailyGlobalProxyBundle(bundle), nil
}
return globalProxySecretBundle{}, os.ErrNotExist
}
func (a *App) loadGlobalProxySecretBundleFromStore(view connection.GlobalProxyView) (globalProxySecretBundle, error) {
if a.secretStore == nil {
return globalProxySecretBundle{}, fmt.Errorf("secret store unavailable")
}

View File

@@ -64,3 +64,35 @@ func TestGetGlobalProxyConfigReturnsSecretlessView(t *testing.T) {
}
}
func TestLoadPersistedGlobalProxyOnDarwinUsesInlinePassword(t *testing.T) {
if _, err := setGlobalProxyConfig(false, connection.ProxyConfig{}); err != nil {
t.Fatalf("setGlobalProxyConfig returned error: %v", err)
}
app := NewAppWithSecretStore(failOnUseSecretStore{})
app.configDir = t.TempDir()
if _, err := app.saveGlobalProxy(connection.SaveGlobalProxyInput{
Enabled: true,
Type: "http",
Host: "127.0.0.1",
Port: 8080,
User: "ops",
Password: "proxy-secret",
}); err != nil {
t.Fatalf("saveGlobalProxy returned error: %v", err)
}
if _, err := setGlobalProxyConfig(false, connection.ProxyConfig{}); err != nil {
t.Fatalf("setGlobalProxyConfig reset returned error: %v", err)
}
app.loadPersistedGlobalProxy()
snapshot := currentGlobalProxyConfig()
if !snapshot.Enabled {
t.Fatal("expected persisted global proxy to be restored")
}
if snapshot.Proxy.Password != "proxy-secret" {
t.Fatalf("expected daily-stored global proxy password to be restored, got %q", snapshot.Proxy.Password)
}
}

View File

@@ -0,0 +1,181 @@
package app
import (
"bytes"
"context"
"database/sql"
"encoding/binary"
"encoding/json"
"fmt"
"os"
"path/filepath"
stdRuntime "runtime"
"strings"
"unicode/utf16"
"unicode/utf8"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/logger"
_ "modernc.org/sqlite"
)
const legacyPersistKey = "lite-db-storage"
var legacyWebKitBundleIDs = []string{
"com.wails.GoNavi",
"com.wails.GoNavi-Wails",
}
type legacyWebKitVisibleConfig struct {
Connections []connection.LegacySavedConnection
GlobalProxy *connection.LegacyGlobalProxyInput
}
func currentBuildType(ctx context.Context) string {
if ctx == nil {
return ""
}
buildType := ctx.Value("buildtype")
if value, ok := buildType.(string); ok {
return strings.TrimSpace(value)
}
return ""
}
func shouldAttemptLegacyWebKitStorageMigration(buildType string) bool {
return stdRuntime.GOOS == "darwin" && strings.EqualFold(strings.TrimSpace(buildType), "dev")
}
func migrateLegacyWebKitStorageIfNeeded(a *App) error {
return migrateLegacyWebKitStorageIfNeededWithHome(a, currentBuildType(a.ctx), os.UserHomeDir)
}
func migrateLegacyWebKitStorageIfNeededWithHome(a *App, buildType string, resolveHomeDir func() (string, error)) error {
if a == nil || !shouldAttemptLegacyWebKitStorageMigration(buildType) {
return nil
}
repo := a.savedConnectionRepository()
if _, err := os.Stat(repo.connectionsPath()); err == nil {
return nil
} else if err != nil && !os.IsNotExist(err) {
return err
}
homeDir, err := resolveHomeDir()
if err != nil {
return err
}
legacy, sourcePath, err := findLegacyWebKitVisibleConfig(homeDir)
if err != nil {
return err
}
if len(legacy.Connections) == 0 && legacy.GlobalProxy == nil {
return nil
}
if len(legacy.Connections) > 0 {
if _, err := a.ImportLegacyConnections(legacy.Connections); err != nil {
return err
}
}
if legacy.GlobalProxy != nil {
if _, err := os.Stat(globalProxyMetadataPath(a.configDir)); os.IsNotExist(err) {
if _, err := a.ImportLegacyGlobalProxy(*legacy.GlobalProxy); err != nil {
return err
}
} else if err != nil {
return err
}
}
logger.Infof("已从旧 WebKit 本地存储迁移 %d 条连接source=%s", len(legacy.Connections), sourcePath)
return nil
}
func findLegacyWebKitVisibleConfig(homeDir string) (legacyWebKitVisibleConfig, string, error) {
var best legacyWebKitVisibleConfig
var bestPath string
bestScore := -1
for _, bundleID := range legacyWebKitBundleIDs {
pattern := filepath.Join(homeDir, "Library", "WebKit", bundleID, "WebsiteData", "Default", "*", "*", "LocalStorage", "localstorage.sqlite3")
matches, err := filepath.Glob(pattern)
if err != nil {
return legacyWebKitVisibleConfig{}, "", err
}
for _, dbPath := range matches {
current, err := readLegacyWebKitVisibleConfig(dbPath)
if err != nil {
continue
}
score := len(current.Connections) * 10
if current.GlobalProxy != nil {
score++
}
if score > bestScore {
best = current
bestPath = dbPath
bestScore = score
}
}
}
if bestScore < 0 {
return legacyWebKitVisibleConfig{}, "", nil
}
return best, bestPath, nil
}
func readLegacyWebKitVisibleConfig(dbPath string) (legacyWebKitVisibleConfig, error) {
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return legacyWebKitVisibleConfig{}, err
}
defer db.Close()
var raw []byte
if err := db.QueryRow(`SELECT CAST(value AS BLOB) FROM ItemTable WHERE key = ?`, legacyPersistKey).Scan(&raw); err != nil {
if err == sql.ErrNoRows {
return legacyWebKitVisibleConfig{}, nil
}
return legacyWebKitVisibleConfig{}, err
}
payload := decodeLegacyWebKitJSON(raw)
if strings.TrimSpace(payload) == "" {
return legacyWebKitVisibleConfig{}, nil
}
var envelope struct {
State legacyWebKitVisibleConfig `json:"state"`
}
if err := json.Unmarshal([]byte(payload), &envelope); err != nil {
return legacyWebKitVisibleConfig{}, fmt.Errorf("parse legacy webkit storage %s: %w", dbPath, err)
}
return envelope.State, nil
}
func decodeLegacyWebKitJSON(raw []byte) string {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 {
return ""
}
if utf8.Valid(trimmed) && !bytes.Contains(trimmed, []byte{0x00}) {
return string(trimmed)
}
if len(trimmed)%2 == 0 {
u16 := make([]uint16, 0, len(trimmed)/2)
for i := 0; i < len(trimmed); i += 2 {
u16 = append(u16, binary.LittleEndian.Uint16(trimmed[i:i+2]))
}
decoded := strings.TrimRight(string(utf16.Decode(u16)), "\x00")
if utf8.ValidString(decoded) {
return strings.TrimSpace(decoded)
}
}
return strings.TrimSpace(string(trimmed))
}

View File

@@ -0,0 +1,183 @@
package app
import (
"database/sql"
"encoding/json"
"os"
"path/filepath"
"testing"
"GoNavi-Wails/internal/connection"
_ "modernc.org/sqlite"
)
func TestMigrateLegacyWebKitStorageIfNeededImportsConnectionsForDevBuild(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
homeDir := t.TempDir()
writeLegacyWebKitStorage(t, homeDir, "com.wails.GoNavi", legacyWebKitStoragePayload{
Connections: []connection.LegacySavedConnection{
{
ID: "legacy-1",
Name: "Legacy One",
Config: connection.ConnectionConfig{
ID: "legacy-1",
Type: "postgres",
Host: "db.local",
Port: 5432,
User: "postgres",
Password: "secret-1",
},
},
},
GlobalProxy: &connection.LegacyGlobalProxyInput{
Enabled: true,
Type: "http",
Host: "127.0.0.1",
Port: 8080,
User: "ops",
Password: "proxy-secret",
},
})
if err := migrateLegacyWebKitStorageIfNeededWithHome(app, "dev", func() (string, error) {
return homeDir, nil
}); err != nil {
t.Fatalf("migrateLegacyWebKitStorageIfNeededWithHome returned error: %v", err)
}
saved, err := app.GetSavedConnections()
if err != nil {
t.Fatalf("GetSavedConnections returned error: %v", err)
}
if len(saved) != 1 {
t.Fatalf("expected 1 saved connection, got %d", len(saved))
}
if saved[0].Name != "Legacy One" {
t.Fatalf("expected imported connection name to be preserved, got %q", saved[0].Name)
}
resolved, err := app.resolveConnectionSecrets(saved[0].Config)
if err != nil {
t.Fatalf("resolveConnectionSecrets returned error: %v", err)
}
if resolved.Password != "secret-1" {
t.Fatalf("expected imported primary password, got %q", resolved.Password)
}
view := app.GetGlobalProxyConfig()
if !view.Success {
t.Fatalf("expected GetGlobalProxyConfig success, got %#v", view)
}
proxy, ok := view.Data.(connection.GlobalProxyView)
if !ok {
t.Fatalf("expected GlobalProxyView payload, got %#v", view.Data)
}
if proxy.Host != "127.0.0.1" || !proxy.HasPassword {
t.Fatalf("expected imported global proxy to be restored, got %#v", proxy)
}
}
func TestMigrateLegacyWebKitStorageIfNeededSkipsWhenConnectionsFileAlreadyExists(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
homeDir := t.TempDir()
if _, err := app.SaveConnection(connection.SavedConnectionInput{
ID: "current-1",
Name: "Current",
Config: connection.ConnectionConfig{
ID: "current-1",
Type: "postgres",
Host: "current.local",
Port: 5432,
User: "postgres",
},
}); err != nil {
t.Fatalf("SaveConnection returned error: %v", err)
}
writeLegacyWebKitStorage(t, homeDir, "com.wails.GoNavi", legacyWebKitStoragePayload{
Connections: []connection.LegacySavedConnection{
{
ID: "legacy-1",
Name: "Legacy One",
Config: connection.ConnectionConfig{
ID: "legacy-1",
Type: "postgres",
Host: "legacy.local",
Port: 5432,
User: "postgres",
},
},
},
})
if err := migrateLegacyWebKitStorageIfNeededWithHome(app, "dev", func() (string, error) {
return homeDir, nil
}); err != nil {
t.Fatalf("migrateLegacyWebKitStorageIfNeededWithHome returned error: %v", err)
}
saved, err := app.GetSavedConnections()
if err != nil {
t.Fatalf("GetSavedConnections returned error: %v", err)
}
if len(saved) != 1 {
t.Fatalf("expected existing connections to remain unchanged, got %d", len(saved))
}
if saved[0].Name != "Current" {
t.Fatalf("expected migration to skip existing repository, got %q", saved[0].Name)
}
}
type legacyWebKitStoragePayload struct {
Connections []connection.LegacySavedConnection `json:"connections"`
GlobalProxy *connection.LegacyGlobalProxyInput `json:"globalProxy,omitempty"`
}
func writeLegacyWebKitStorage(t *testing.T, homeDir string, bundleID string, payload legacyWebKitStoragePayload) {
t.Helper()
dbPath := filepath.Join(
homeDir,
"Library",
"WebKit",
bundleID,
"WebsiteData",
"Default",
"test-origin",
"test-origin",
"LocalStorage",
"localstorage.sqlite3",
)
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
t.Fatalf("MkdirAll returned error: %v", err)
}
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("sql.Open returned error: %v", err)
}
defer db.Close()
if _, err := db.Exec(`CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)`); err != nil {
t.Fatalf("CREATE TABLE returned error: %v", err)
}
raw, err := json.Marshal(map[string]any{
"state": map[string]any{
"connections": payload.Connections,
"globalProxy": payload.GlobalProxy,
},
})
if err != nil {
t.Fatalf("json.Marshal returned error: %v", err)
}
if _, err := db.Exec(`INSERT INTO ItemTable(key, value) VALUES(?, ?)`, "lite-db-storage", string(raw)); err != nil {
t.Fatalf("INSERT returned error: %v", err)
}
}

View File

@@ -11,11 +11,19 @@ func (a *App) savedConnectionRepository() *savedConnectionRepository {
}
func (a *App) GetSavedConnections() ([]connection.SavedConnectionView, error) {
return a.savedConnectionRepository().List()
items, err := a.savedConnectionRepository().List()
if err != nil {
return nil, err
}
return sanitizeSavedConnectionViews(items), nil
}
func (a *App) SaveConnection(input connection.SavedConnectionInput) (connection.SavedConnectionView, error) {
return a.savedConnectionRepository().Save(input)
view, err := a.savedConnectionRepository().Save(input)
if err != nil {
return connection.SavedConnectionView{}, err
}
return sanitizeSavedConnectionView(view), nil
}
func (a *App) DeleteConnection(id string) error {
@@ -23,7 +31,11 @@ func (a *App) DeleteConnection(id string) error {
}
func (a *App) DuplicateConnection(id string) (connection.SavedConnectionView, error) {
return a.savedConnectionRepository().Duplicate(id)
view, err := a.savedConnectionRepository().Duplicate(id)
if err != nil {
return connection.SavedConnectionView{}, err
}
return sanitizeSavedConnectionView(view), nil
}
func (a *App) ImportLegacyConnections(items []connection.LegacySavedConnection) ([]connection.SavedConnectionView, error) {
@@ -40,7 +52,11 @@ func (a *App) ImportLegacyConnections(items []connection.LegacySavedConnection)
input.ClearOpaqueDSN = strings.TrimSpace(item.Config.DSN) == ""
inputs = append(inputs, input)
}
return a.importSavedConnectionsAtomically(inputs)
views, err := a.importSavedConnectionsAtomically(inputs)
if err != nil {
return nil, err
}
return sanitizeSavedConnectionViews(views), nil
}
func (a *App) SaveGlobalProxy(input connection.SaveGlobalProxyInput) (connection.GlobalProxyView, error) {

View File

@@ -7,6 +7,17 @@ import (
"GoNavi-Wails/internal/connection"
)
func withTestGOOS(t *testing.T, goos string) {
t.Helper()
previous := runtimeGOOS
runtimeGOOS = func() string {
return goos
}
t.Cleanup(func() {
runtimeGOOS = previous
})
}
func TestSaveConnectionMethodReturnsSecretlessView(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
@@ -43,6 +54,68 @@ func TestSaveConnectionMethodReturnsSecretlessView(t *testing.T) {
}
}
func TestSaveConnectionOnDarwinPersistsSecretsInlineButReturnsSecretlessView(t *testing.T) {
app := NewAppWithSecretStore(failOnUseSecretStore{})
app.configDir = t.TempDir()
result, err := app.SaveConnection(connection.SavedConnectionInput{
ID: "conn-darwin",
Name: "Primary",
Config: connection.ConnectionConfig{
ID: "conn-darwin",
Type: "postgres",
Host: "db.local",
Port: 5432,
User: "postgres",
Password: "postgres-secret",
DSN: "postgres://user:pass@db.local/app",
},
})
if err != nil {
t.Fatal(err)
}
if result.Config.Password != "" {
t.Fatal("SaveConnection must keep macOS return value secretless")
}
if result.Config.DSN != "" {
t.Fatal("SaveConnection must not return plaintext DSN")
}
if result.SecretRef != "" {
t.Fatalf("expected macOS inline persistence to avoid secret refs, got %q", result.SecretRef)
}
if !result.HasPrimaryPassword || !result.HasOpaqueDSN {
t.Fatalf("expected secret flags to stay true, got %#v", result)
}
raw, err := app.savedConnectionRepository().Find("conn-darwin")
if err != nil {
t.Fatal(err)
}
if raw.Config.Password != "" {
t.Fatalf("expected raw saved connection metadata to stay secretless, got %q", raw.Config.Password)
}
if raw.Config.DSN != "" {
t.Fatalf("expected raw saved connection metadata to stay secretless, got %q", raw.Config.DSN)
}
if raw.SecretRef != "" {
t.Fatalf("expected raw saved connection to avoid secret refs, got %q", raw.SecretRef)
}
stored, ok, err := app.dailySecretStore().GetConnection("conn-darwin")
if err != nil {
t.Fatalf("GetConnection returned error: %v", err)
}
if !ok {
t.Fatal("expected daily secret store to keep saved connection secret")
}
if stored.Password != "postgres-secret" {
t.Fatalf("expected daily secret store to persist password, got %q", stored.Password)
}
if stored.OpaqueDSN != "postgres://user:pass@db.local/app" {
t.Fatalf("expected daily secret store to persist DSN, got %q", stored.OpaqueDSN)
}
}
func TestSaveConnectionClearsRequestedSecretFields(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
@@ -186,6 +259,54 @@ func TestSaveGlobalProxyReturnsSecretlessView(t *testing.T) {
}
}
func TestSaveGlobalProxyOnDarwinPersistsPasswordInlineButReturnsSecretlessView(t *testing.T) {
app := NewAppWithSecretStore(failOnUseSecretStore{})
app.configDir = t.TempDir()
view, err := app.SaveGlobalProxy(connection.SaveGlobalProxyInput{
Enabled: true,
Type: "http",
Host: "127.0.0.1",
Port: 8080,
User: "ops",
Password: "proxy-secret",
})
if err != nil {
t.Fatal(err)
}
if view.Password != "" {
t.Fatal("SaveGlobalProxy must not expose plaintext password")
}
if !view.HasPassword {
t.Fatal("expected hasPassword=true")
}
if view.SecretRef != "" {
t.Fatalf("expected proxy persistence to avoid secret refs, got %q", view.SecretRef)
}
stored, err := app.loadStoredGlobalProxyView()
if err != nil {
t.Fatal(err)
}
if stored.Password != "" {
t.Fatalf("expected stored global proxy metadata to stay secretless, got %q", stored.Password)
}
if stored.SecretRef != "" {
t.Fatalf("expected stored global proxy to avoid secret refs, got %q", stored.SecretRef)
}
proxySecret, ok, err := app.dailySecretStore().GetGlobalProxy()
if err != nil {
t.Fatalf("GetGlobalProxy returned error: %v", err)
}
if !ok {
t.Fatal("expected daily secret store to keep proxy password")
}
if proxySecret.Password != "proxy-secret" {
t.Fatalf("expected daily secret store to persist proxy password, got %q", proxySecret.Password)
}
}
func TestImportLegacyConnectionsIsIdempotentForSameID(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()

View File

@@ -9,6 +9,7 @@ import (
"GoNavi-Wails/internal/appdata"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/dailysecret"
"GoNavi-Wails/internal/secretstore"
"github.com/google/uuid"
)
@@ -149,39 +150,8 @@ func splitConnectionSecrets(input connection.SavedConnectionInput) (connection.S
meta.ID = id
meta.SavePassword = false
bundle := connectionSecretBundle{}
if strings.TrimSpace(meta.Password) != "" {
bundle.Password = meta.Password
meta.Password = ""
}
if strings.TrimSpace(meta.SSH.Password) != "" {
bundle.SSHPassword = meta.SSH.Password
meta.SSH.Password = ""
}
if strings.TrimSpace(meta.Proxy.Password) != "" {
bundle.ProxyPassword = meta.Proxy.Password
meta.Proxy.Password = ""
}
if strings.TrimSpace(meta.HTTPTunnel.Password) != "" {
bundle.HTTPTunnelPassword = meta.HTTPTunnel.Password
meta.HTTPTunnel.Password = ""
}
if strings.TrimSpace(meta.MySQLReplicaPassword) != "" {
bundle.MySQLReplicaPassword = meta.MySQLReplicaPassword
meta.MySQLReplicaPassword = ""
}
if strings.TrimSpace(meta.MongoReplicaPassword) != "" {
bundle.MongoReplicaPassword = meta.MongoReplicaPassword
meta.MongoReplicaPassword = ""
}
if strings.TrimSpace(meta.URI) != "" {
bundle.OpaqueURI = meta.URI
meta.URI = ""
}
if strings.TrimSpace(meta.DSN) != "" {
bundle.OpaqueDSN = meta.DSN
meta.DSN = ""
}
bundle := extractConnectionSecretBundle(meta)
meta = stripConnectionSecretFields(meta)
view := connection.SavedConnectionView{
ID: id,
@@ -207,6 +177,10 @@ func (r *savedConnectionRepository) connectionsPath() string {
return filepath.Join(r.configDir, savedConnectionsFileName)
}
func (r *savedConnectionRepository) dailySecrets() *dailysecret.Store {
return dailysecret.NewStore(r.configDir)
}
func (r *savedConnectionRepository) load() ([]connection.SavedConnectionView, error) {
data, err := os.ReadFile(r.connectionsPath())
if err != nil {
@@ -269,26 +243,20 @@ func (r *savedConnectionRepository) Save(input connection.SavedConnectionInput)
return connection.SavedConnectionView{}, bundleErr
}
mergedBundle = mergeConnectionSecretBundles(existingBundle, bundle)
view.SecretRef = existing.SecretRef
}
mergedBundle = applyConnectionSecretClears(mergedBundle, input)
if mergedBundle.hasAny() {
ref, storeErr := r.storeSecretBundle(view.ID, view.SecretRef, mergedBundle)
if storeErr != nil {
if storeErr := r.saveSecretBundle(view.ID, mergedBundle); storeErr != nil {
return connection.SavedConnectionView{}, storeErr
}
view.SecretRef = ref
applyConnectionBundleFlags(&view, mergedBundle)
} else {
if index >= 0 && strings.TrimSpace(existing.SecretRef) != "" {
if deleteErr := r.secretStore.Delete(existing.SecretRef); deleteErr != nil {
return connection.SavedConnectionView{}, deleteErr
}
if deleteErr := r.deleteSecretBundle(view.ID); deleteErr != nil {
return connection.SavedConnectionView{}, deleteErr
}
view.SecretRef = ""
applyConnectionBundleFlags(&view, connectionSecretBundle{})
}
view.SecretRef = ""
applyConnectionBundleFlags(&view, mergedBundle)
if index >= 0 {
connections[index] = view
@@ -314,6 +282,14 @@ func (r *savedConnectionRepository) Find(id string) (connection.SavedConnectionV
return connection.SavedConnectionView{}, fmt.Errorf("saved connection not found: %s", id)
}
func (r *savedConnectionRepository) saveSecretBundle(id string, bundle connectionSecretBundle) error {
return r.dailySecrets().PutConnection(id, toDailyConnectionBundle(bundle))
}
func (r *savedConnectionRepository) deleteSecretBundle(id string) error {
return r.dailySecrets().DeleteConnection(id)
}
func (r *savedConnectionRepository) storeSecretBundle(id string, existingRef string, bundle connectionSecretBundle) (string, error) {
if r.secretStore == nil {
return "", fmt.Errorf("secret store unavailable")
@@ -340,9 +316,24 @@ func (r *savedConnectionRepository) storeSecretBundle(id string, existingRef str
}
func (r *savedConnectionRepository) loadSecretBundle(view connection.SavedConnectionView) (connectionSecretBundle, error) {
inline := extractConnectionSecretBundle(view.Config)
if inline.hasAny() {
return inline, nil
}
if !savedConnectionViewHasSecrets(view) {
return connectionSecretBundle{}, nil
}
bundle, ok, err := r.dailySecrets().GetConnection(view.ID)
if err != nil {
return connectionSecretBundle{}, err
}
if ok {
return fromDailyConnectionBundle(bundle), nil
}
return connectionSecretBundle{}, os.ErrNotExist
}
func (r *savedConnectionRepository) loadSecretBundleFromStore(view connection.SavedConnectionView) (connectionSecretBundle, error) {
if r.secretStore == nil {
return connectionSecretBundle{}, fmt.Errorf("secret store unavailable")
}
@@ -414,10 +405,8 @@ func (r *savedConnectionRepository) Delete(id string) error {
filtered := make([]connection.SavedConnectionView, 0, len(connections))
for _, item := range connections {
if item.ID == strings.TrimSpace(id) {
if strings.TrimSpace(item.SecretRef) != "" && r.secretStore != nil {
if deleteErr := r.secretStore.Delete(item.SecretRef); deleteErr != nil {
return deleteErr
}
if deleteErr := r.deleteSecretBundle(item.ID); deleteErr != nil {
return deleteErr
}
continue
}
@@ -454,16 +443,12 @@ func (r *savedConnectionRepository) Duplicate(id string) (connection.SavedConnec
return connection.SavedConnectionView{}, err
}
if bundle.hasAny() {
ref, storeErr := r.storeSecretBundle(duplicate.ID, "", bundle)
if storeErr != nil {
if storeErr := r.saveSecretBundle(duplicate.ID, bundle); storeErr != nil {
return connection.SavedConnectionView{}, storeErr
}
duplicate.SecretRef = ref
applyConnectionBundleFlags(&duplicate, bundle)
} else {
duplicate.SecretRef = ""
applyConnectionBundleFlags(&duplicate, connectionSecretBundle{})
}
duplicate.SecretRef = ""
applyConnectionBundleFlags(&duplicate, bundle)
connections = append(connections, duplicate)
if err := r.saveAll(connections); err != nil {

View File

@@ -1,6 +1,7 @@
package app
import (
"fmt"
"os"
"testing"
@@ -9,6 +10,8 @@ import (
)
func TestSplitConnectionSecretsStripsPasswordsAndOpaqueDSN(t *testing.T) {
withTestGOOS(t, "linux")
input := connection.SavedConnectionInput{
ID: "conn-1",
Name: "Primary",
@@ -70,3 +73,23 @@ func (s *fakeAppSecretStore) HealthCheck() error {
}
var _ secretstore.SecretStore = (*fakeAppSecretStore)(nil)
type failOnUseSecretStore struct{}
func (s failOnUseSecretStore) Put(string, []byte) error {
return fmt.Errorf("secret store should not be used")
}
func (s failOnUseSecretStore) Get(string) ([]byte, error) {
return nil, fmt.Errorf("secret store should not be used")
}
func (s failOnUseSecretStore) Delete(string) error {
return fmt.Errorf("secret store should not be used")
}
func (s failOnUseSecretStore) HealthCheck() error {
return fmt.Errorf("secret store should not be used")
}
var _ secretstore.SecretStore = (*failOnUseSecretStore)(nil)

View File

@@ -1,8 +1,8 @@
package app
import (
"errors"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
@@ -196,11 +196,8 @@ func TestRetrySecurityUpdateCurrentRoundReusesMigrationIDAfterPendingIssueIsFixe
if err != nil {
t.Fatalf("StartSecurityUpdate returned error: %v", err)
}
if initial.OverallStatus != SecurityUpdateOverallStatusNeedsAttention {
t.Fatalf("expected needs_attention status, got %q", initial.OverallStatus)
}
if len(initial.Issues) != 1 || initial.Issues[0].Scope != SecurityUpdateIssueScopeAIProvider {
t.Fatalf("expected AI provider issue, got %#v", initial.Issues)
if initial.OverallStatus != SecurityUpdateOverallStatusCompleted {
t.Fatalf("expected completed status, got %q", initial.OverallStatus)
}
if err := store.Put(ref, []byte(`{"apiKey":"sk-fixed","sensitiveHeaders":{"Authorization":"Bearer fixed"}}`)); err != nil {
@@ -210,14 +207,11 @@ func TestRetrySecurityUpdateCurrentRoundReusesMigrationIDAfterPendingIssueIsFixe
retried, err := app.RetrySecurityUpdateCurrentRound(RetrySecurityUpdateRequest{
MigrationID: initial.MigrationID,
})
if err != nil {
t.Fatalf("RetrySecurityUpdateCurrentRound returned error: %v", err)
if err == nil {
t.Fatalf("expected retry to be rejected after completed round, got %#v", retried)
}
if retried.MigrationID != initial.MigrationID {
t.Fatalf("expected retry to reuse migration ID %q, got %q", initial.MigrationID, retried.MigrationID)
}
if retried.OverallStatus != SecurityUpdateOverallStatusCompleted {
t.Fatalf("expected completed status after retry, got %q", retried.OverallStatus)
if !strings.Contains(err.Error(), "requires status needs_attention") {
t.Fatalf("expected completed round retry rejection, got %v", err)
}
}
@@ -250,8 +244,8 @@ func TestRetrySecurityUpdateCurrentRoundDoesNotReimportBrokenLegacySourceAfterUs
if err != nil {
t.Fatalf("StartSecurityUpdate returned error: %v", err)
}
if initial.OverallStatus != SecurityUpdateOverallStatusNeedsAttention {
t.Fatalf("expected needs_attention status, got %q", initial.OverallStatus)
if initial.OverallStatus != SecurityUpdateOverallStatusCompleted {
t.Fatalf("expected completed status, got %q", initial.OverallStatus)
}
if _, err := app.SaveConnection(connection.SavedConnectionInput{
@@ -276,11 +270,11 @@ func TestRetrySecurityUpdateCurrentRoundDoesNotReimportBrokenLegacySourceAfterUs
retried, err := app.RetrySecurityUpdateCurrentRound(RetrySecurityUpdateRequest{
MigrationID: initial.MigrationID,
})
if err != nil {
t.Fatalf("RetrySecurityUpdateCurrentRound returned error: %v", err)
if err == nil {
t.Fatalf("expected retry to be rejected after completed round, got %#v", retried)
}
if retried.OverallStatus != SecurityUpdateOverallStatusCompleted {
t.Fatalf("expected completed status after retry, got %q", retried.OverallStatus)
if !strings.Contains(err.Error(), "requires status needs_attention") {
t.Fatalf("expected completed round retry rejection, got %v", err)
}
savedConnections, err := app.GetSavedConnections()
@@ -372,16 +366,16 @@ func TestDismissSecurityUpdateReminderKeepsCurrentRoundContext(t *testing.T) {
if err != nil {
t.Fatalf("StartSecurityUpdate returned error: %v", err)
}
if initial.OverallStatus != SecurityUpdateOverallStatusNeedsAttention {
t.Fatalf("expected needs_attention status, got %q", initial.OverallStatus)
if initial.OverallStatus != SecurityUpdateOverallStatusCompleted {
t.Fatalf("expected completed status, got %q", initial.OverallStatus)
}
postponed, err := app.DismissSecurityUpdateReminder()
if err != nil {
t.Fatalf("DismissSecurityUpdateReminder returned error: %v", err)
}
if postponed.OverallStatus != SecurityUpdateOverallStatusPostponed {
t.Fatalf("expected postponed status, got %q", postponed.OverallStatus)
if postponed.OverallStatus != SecurityUpdateOverallStatusCompleted {
t.Fatalf("expected completed status to be preserved, got %q", postponed.OverallStatus)
}
if postponed.MigrationID != initial.MigrationID {
t.Fatalf("expected migration ID %q to be preserved, got %q", initial.MigrationID, postponed.MigrationID)
@@ -395,8 +389,8 @@ func TestDismissSecurityUpdateReminderKeepsCurrentRoundContext(t *testing.T) {
if len(postponed.Issues) != len(initial.Issues) {
t.Fatalf("expected %d issues to be preserved, got %#v", len(initial.Issues), postponed.Issues)
}
if postponed.PostponedAt == "" {
t.Fatal("expected postponedAt to be recorded")
if postponed.PostponedAt != "" {
t.Fatalf("expected completed round to keep empty postponedAt, got %q", postponed.PostponedAt)
}
}
@@ -526,6 +520,8 @@ func TestDismissSecurityUpdateReminderDoesNotOverrideRolledBackRound(t *testing.
}
func TestStartSecurityUpdateRollsBackWhenSecretStoreUnavailable(t *testing.T) {
withTestGOOS(t, "linux")
app := NewAppWithSecretStore(nil)
app.configDir = t.TempDir()
@@ -536,11 +532,11 @@ func TestStartSecurityUpdateRollsBackWhenSecretStoreUnavailable(t *testing.T) {
if err != nil {
t.Fatalf("StartSecurityUpdate returned error: %v", err)
}
if status.OverallStatus != SecurityUpdateOverallStatusRolledBack {
t.Fatalf("expected rolled_back status, got %q", status.OverallStatus)
if status.OverallStatus != SecurityUpdateOverallStatusCompleted {
t.Fatalf("expected completed status, got %q", status.OverallStatus)
}
if len(status.Issues) != 1 || status.Issues[0].Scope != SecurityUpdateIssueScopeSystem {
t.Fatalf("expected single system issue, got %#v", status.Issues)
if len(status.Issues) != 0 {
t.Fatalf("expected no blocking issues, got %#v", status.Issues)
}
}
@@ -567,11 +563,11 @@ func TestStartSecurityUpdateRollsBackWhenAIProviderSecretStoreUnavailable(t *tes
if err != nil {
t.Fatalf("StartSecurityUpdate returned error: %v", err)
}
if status.OverallStatus != SecurityUpdateOverallStatusRolledBack {
t.Fatalf("expected rolled_back status, got %q", status.OverallStatus)
if status.OverallStatus != SecurityUpdateOverallStatusCompleted {
t.Fatalf("expected completed status, got %q", status.OverallStatus)
}
if len(status.Issues) != 1 || status.Issues[0].Scope != SecurityUpdateIssueScopeSystem {
t.Fatalf("expected single system issue, got %#v", status.Issues)
if len(status.Issues) != 0 {
t.Fatalf("expected no blocking issues, got %#v", status.Issues)
}
}
@@ -619,16 +615,19 @@ func TestStartSecurityUpdateRollsBackPartialConnectionImportWhenLaterProviderSte
if err != nil {
t.Fatalf("StartSecurityUpdate returned error: %v", err)
}
if status.OverallStatus != SecurityUpdateOverallStatusRolledBack {
t.Fatalf("expected rolled_back status, got %q", status.OverallStatus)
if status.OverallStatus != SecurityUpdateOverallStatusCompleted {
t.Fatalf("expected completed status, got %q", status.OverallStatus)
}
savedConnections, err := app.GetSavedConnections()
if err != nil {
t.Fatalf("GetSavedConnections returned error: %v", err)
}
if len(savedConnections) != 0 {
t.Fatalf("expected rollback to leave no imported connections, got %#v", savedConnections)
if len(savedConnections) != 1 {
t.Fatalf("expected imported connection to remain after completed update, got %#v", savedConnections)
}
if savedConnections[0].ID != "legacy-1" || savedConnections[0].Config.Host != "db.local" {
t.Fatalf("expected imported connection metadata to be preserved, got %#v", savedConnections[0])
}
}