mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-02 20:49:48 +08:00
🐛 fix(security): 修复 macOS 无法打开应用及三平台依赖系统钥匙串的问题
- 密文存储:新增 dailysecret 本地存储引擎,连接/代理/AI 密钥不再依赖系统钥匙串 - 启动迁移:自动将已有钥匙串密文迁移到本地 JSON,用户无感知 - WebKit 迁移:从旧版 Wails WebKit LocalStorage 中恢复连接与代理数据 - DMG 修复:移除 --sandbox-safe 避免扩展属性污染签名,新增 xattr 清理与签名校验 - 安全适配:钥匙串不可用时标记完成而非回滚,消除无钥匙串环境下的阻塞 - 出口脱敏:所有连接/代理 API 返回前统一 sanitize 防止密文泄漏
This commit is contained in:
@@ -8,7 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/ai"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
"GoNavi-Wails/internal/dailysecret"
|
||||
"GoNavi-Wails/internal/secretstore"
|
||||
)
|
||||
|
||||
@@ -38,8 +38,9 @@ type ProviderConfigStoreInspection struct {
|
||||
}
|
||||
|
||||
type ProviderConfigStore struct {
|
||||
configDir string
|
||||
secretStore secretstore.SecretStore
|
||||
configDir string
|
||||
secretStore secretstore.SecretStore
|
||||
dailySecrets *dailysecret.Store
|
||||
}
|
||||
|
||||
func NewProviderConfigStore(configDir string, store secretstore.SecretStore) *ProviderConfigStore {
|
||||
@@ -50,8 +51,9 @@ func NewProviderConfigStore(configDir string, store secretstore.SecretStore) *Pr
|
||||
store = secretstore.NewUnavailableStore("secret store unavailable")
|
||||
}
|
||||
return &ProviderConfigStore{
|
||||
configDir: configDir,
|
||||
secretStore: store,
|
||||
configDir: configDir,
|
||||
secretStore: store,
|
||||
dailySecrets: dailysecret.NewStore(configDir),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,24 +98,7 @@ func (s *ProviderConfigStore) Load() (ProviderConfigStoreSnapshot, error) {
|
||||
}
|
||||
|
||||
func (s *ProviderConfigStore) LoadRuntime() (ProviderConfigStoreSnapshot, error) {
|
||||
_, snapshot, err := s.readStoredSnapshot()
|
||||
if err != nil {
|
||||
return snapshot, err
|
||||
}
|
||||
|
||||
providers := make([]ai.ProviderConfig, 0, len(snapshot.Providers))
|
||||
for _, providerConfig := range snapshot.Providers {
|
||||
runtimeConfig, loadErr := s.loadRuntimeProviderConfig(providerConfig)
|
||||
if loadErr != nil {
|
||||
logger.Error(loadErr, "加载 AI Provider secret 失败,provider=%s", providerConfig.ID)
|
||||
}
|
||||
providers = append(providers, runtimeConfig)
|
||||
}
|
||||
if providers == nil {
|
||||
providers = []ai.ProviderConfig{}
|
||||
}
|
||||
snapshot.Providers = providers
|
||||
return snapshot, nil
|
||||
return s.Load()
|
||||
}
|
||||
|
||||
func (s *ProviderConfigStore) Inspect() (ProviderConfigStoreInspection, error) {
|
||||
@@ -141,11 +126,17 @@ func (s *ProviderConfigStore) Save(snapshot ProviderConfigStoreSnapshot) error {
|
||||
runtimeConfig := normalizeProviderConfig(providerConfig)
|
||||
meta, bundle := splitProviderSecrets(runtimeConfig)
|
||||
if bundle.hasAny() {
|
||||
storedMeta, err := persistProviderSecretBundle(s.secretStore, meta, bundle)
|
||||
storedMeta, err := persistProviderSecretBundle(s.dailySecrets, meta, bundle)
|
||||
if err != nil {
|
||||
return fmt.Errorf("保存 Provider secret 失败: %w", err)
|
||||
}
|
||||
meta = storedMeta
|
||||
} else if meta.HasSecret {
|
||||
resolved, _, err := s.loadStoredProviderConfig(meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("保存 Provider secret 失败: %w", err)
|
||||
}
|
||||
meta = providerMetadataView(resolved)
|
||||
}
|
||||
providers = append(providers, providerMetadataView(meta))
|
||||
}
|
||||
@@ -219,7 +210,7 @@ func (s *ProviderConfigStore) readStoredSnapshot() (aiConfig, ProviderConfigStor
|
||||
func (s *ProviderConfigStore) loadStoredProviderConfig(config ai.ProviderConfig) (ai.ProviderConfig, bool, error) {
|
||||
meta, bundle := splitProviderSecrets(config)
|
||||
if bundle.hasAny() {
|
||||
storedMeta, err := persistProviderSecretBundle(s.secretStore, meta, bundle)
|
||||
storedMeta, err := persistProviderSecretBundle(s.dailySecrets, meta, bundle)
|
||||
if err != nil {
|
||||
return meta, false, err
|
||||
}
|
||||
@@ -230,33 +221,49 @@ func (s *ProviderConfigStore) loadStoredProviderConfig(config ai.ProviderConfig)
|
||||
return meta, false, nil
|
||||
}
|
||||
|
||||
resolved, err := resolveProviderConfigSecrets(s.secretStore, meta)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return meta, false, nil
|
||||
}
|
||||
if stored, ok, err := s.dailySecrets.GetAIProvider(meta.ID); err != nil {
|
||||
return meta, false, err
|
||||
} else if ok {
|
||||
rewritten := strings.TrimSpace(meta.SecretRef) != ""
|
||||
meta.SecretRef = ""
|
||||
return mergeProviderSecrets(meta, fromDailyProviderBundle(stored)), rewritten, nil
|
||||
}
|
||||
return resolved, false, nil
|
||||
|
||||
if !shouldReadLegacyProviderSecretStore() {
|
||||
meta.HasSecret = false
|
||||
meta.SecretRef = ""
|
||||
return meta, true, nil
|
||||
}
|
||||
|
||||
if strings.TrimSpace(meta.SecretRef) != "" {
|
||||
resolved, err := resolveProviderConfigSecretsFromStore(s.secretStore, meta)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) || secretstore.IsUnavailable(err) {
|
||||
meta.HasSecret = false
|
||||
meta.SecretRef = ""
|
||||
return meta, true, nil
|
||||
}
|
||||
return meta, false, err
|
||||
}
|
||||
_, migratedBundle := splitProviderSecrets(resolved)
|
||||
storedMeta, err := persistProviderSecretBundle(s.dailySecrets, meta, migratedBundle)
|
||||
if err != nil {
|
||||
return meta, false, err
|
||||
}
|
||||
return mergeProviderSecrets(storedMeta, migratedBundle), true, nil
|
||||
}
|
||||
|
||||
meta.HasSecret = false
|
||||
meta.SecretRef = ""
|
||||
return meta, true, nil
|
||||
}
|
||||
|
||||
func (s *ProviderConfigStore) loadRuntimeProviderConfig(config ai.ProviderConfig) (ai.ProviderConfig, error) {
|
||||
meta, bundle := splitProviderSecrets(config)
|
||||
if bundle.hasAny() {
|
||||
return mergeProviderSecrets(meta, bundle), nil
|
||||
}
|
||||
if !meta.HasSecret {
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
resolved, err := resolveProviderConfigSecrets(s.secretStore, meta)
|
||||
if err != nil {
|
||||
return meta, err
|
||||
}
|
||||
return resolved, nil
|
||||
runtimeConfig, _, err := s.loadStoredProviderConfig(config)
|
||||
return runtimeConfig, err
|
||||
}
|
||||
|
||||
func providerNeedsMigration(config ai.ProviderConfig) bool {
|
||||
_, bundle := splitProviderSecrets(normalizeProviderConfig(config))
|
||||
return bundle.hasAny()
|
||||
return bundle.hasAny() || strings.TrimSpace(config.SecretRef) != ""
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@ import (
|
||||
)
|
||||
|
||||
func TestProviderConfigStoreLoadMigratesPlaintextProviderSecrets(t *testing.T) {
|
||||
store := newFakeProviderSecretStore()
|
||||
configStore := newProviderConfigStore(t.TempDir(), store)
|
||||
configStore := newProviderConfigStore(t.TempDir(), failOnUseSecretStore{})
|
||||
|
||||
legacy := aiConfig{
|
||||
Providers: []ai.ProviderConfig{
|
||||
@@ -52,16 +51,15 @@ func TestProviderConfigStoreLoadMigratesPlaintextProviderSecrets(t *testing.T) {
|
||||
t.Fatalf("expected runtime provider to restore sensitive header, got %#v", snapshot.Providers[0].Headers)
|
||||
}
|
||||
|
||||
stored, err := store.Get(snapshot.Providers[0].SecretRef)
|
||||
stored, ok, err := configStore.dailySecrets.GetAIProvider("openai-main")
|
||||
if err != nil {
|
||||
t.Fatalf("expected migrated provider secret bundle, got %v", err)
|
||||
t.Fatalf("GetAIProvider returned error: %v", err)
|
||||
}
|
||||
var bundle providerSecretBundle
|
||||
if err := json.Unmarshal(stored, &bundle); err != nil {
|
||||
t.Fatalf("Unmarshal returned error: %v", err)
|
||||
if !ok {
|
||||
t.Fatal("expected migrated provider secret bundle in daily store")
|
||||
}
|
||||
if bundle.APIKey != "sk-test" {
|
||||
t.Fatalf("expected migrated apiKey in store, got %q", bundle.APIKey)
|
||||
if stored.APIKey != "sk-test" {
|
||||
t.Fatalf("expected migrated apiKey in store, got %q", stored.APIKey)
|
||||
}
|
||||
|
||||
rewritten, err := os.ReadFile(filepath.Join(configStore.configDir, aiConfigFileName))
|
||||
@@ -78,8 +76,7 @@ func TestProviderConfigStoreLoadMigratesPlaintextProviderSecrets(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProviderConfigStoreSavePersistsSecretlessMetadata(t *testing.T) {
|
||||
store := newFakeProviderSecretStore()
|
||||
configStore := newProviderConfigStore(t.TempDir(), store)
|
||||
configStore := newProviderConfigStore(t.TempDir(), failOnUseSecretStore{})
|
||||
|
||||
err := configStore.Save(ProviderConfigStoreSnapshot{
|
||||
Providers: []ai.ProviderConfig{
|
||||
@@ -115,27 +112,24 @@ func TestProviderConfigStoreSavePersistsSecretlessMetadata(t *testing.T) {
|
||||
t.Fatalf("expected config file to remove sensitive headers, got %s", text)
|
||||
}
|
||||
|
||||
ref, err := secretstore.BuildRef(providerSecretKind, "openai-main")
|
||||
stored, ok, err := configStore.dailySecrets.GetAIProvider("openai-main")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRef returned error: %v", err)
|
||||
t.Fatalf("GetAIProvider returned error: %v", err)
|
||||
}
|
||||
stored, err := store.Get(ref)
|
||||
if err != nil {
|
||||
t.Fatalf("expected provider secret bundle in store, got %v", err)
|
||||
if !ok {
|
||||
t.Fatal("expected provider secret bundle in daily store")
|
||||
}
|
||||
var bundle providerSecretBundle
|
||||
if err := json.Unmarshal(stored, &bundle); err != nil {
|
||||
t.Fatalf("Unmarshal returned error: %v", err)
|
||||
if stored.APIKey != "sk-test" {
|
||||
t.Fatalf("expected stored apiKey, got %q", stored.APIKey)
|
||||
}
|
||||
if bundle.APIKey != "sk-test" {
|
||||
t.Fatalf("expected stored apiKey, got %q", bundle.APIKey)
|
||||
}
|
||||
if bundle.SensitiveHeaders["Authorization"] != "Bearer test" {
|
||||
t.Fatalf("expected stored sensitive header, got %#v", bundle.SensitiveHeaders)
|
||||
if stored.SensitiveHeaders["Authorization"] != "Bearer test" {
|
||||
t.Fatalf("expected stored sensitive header, got %#v", stored.SensitiveHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderConfigStoreSaveKeepsExistingSecretRef(t *testing.T) {
|
||||
withTestAIGOOS(t, "linux")
|
||||
|
||||
store := newFakeProviderSecretStore()
|
||||
configStore := newProviderConfigStore(t.TempDir(), store)
|
||||
|
||||
@@ -178,16 +172,15 @@ func TestProviderConfigStoreSaveKeepsExistingSecretRef(t *testing.T) {
|
||||
t.Fatalf("Save returned error: %v", err)
|
||||
}
|
||||
|
||||
stored, err := store.Get(ref)
|
||||
stored, ok, err := configStore.dailySecrets.GetAIProvider("openai-main")
|
||||
if err != nil {
|
||||
t.Fatalf("expected existing provider secret bundle to remain available, got %v", err)
|
||||
t.Fatalf("GetAIProvider returned error: %v", err)
|
||||
}
|
||||
var bundle providerSecretBundle
|
||||
if err := json.Unmarshal(stored, &bundle); err != nil {
|
||||
t.Fatalf("Unmarshal returned error: %v", err)
|
||||
if !ok {
|
||||
t.Fatal("expected existing provider secret bundle to be migrated to daily store")
|
||||
}
|
||||
if bundle.APIKey != "sk-existing" {
|
||||
t.Fatalf("expected existing apiKey to be kept, got %q", bundle.APIKey)
|
||||
if stored.APIKey != "sk-existing" {
|
||||
t.Fatalf("expected existing apiKey to be kept, got %q", stored.APIKey)
|
||||
}
|
||||
|
||||
snapshot, err := configStore.Load()
|
||||
|
||||
19
internal/ai/service/daily_secret_store_adapter.go
Normal file
19
internal/ai/service/daily_secret_store_adapter.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package aiservice
|
||||
|
||||
import (
|
||||
stdRuntime "runtime"
|
||||
|
||||
"GoNavi-Wails/internal/dailysecret"
|
||||
)
|
||||
|
||||
var aiRuntimeGOOS = func() string {
|
||||
return stdRuntime.GOOS
|
||||
}
|
||||
|
||||
func (s *Service) dailySecretStore() *dailysecret.Store {
|
||||
return dailysecret.NewStore(s.configDir)
|
||||
}
|
||||
|
||||
func shouldReadLegacyProviderSecretStore() bool {
|
||||
return aiRuntimeGOOS() != "darwin"
|
||||
}
|
||||
@@ -3,10 +3,12 @@ package aiservice
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"GoNavi-Wails/internal/ai"
|
||||
"GoNavi-Wails/internal/dailysecret"
|
||||
"GoNavi-Wails/internal/secretstore"
|
||||
)
|
||||
|
||||
@@ -76,11 +78,6 @@ func splitProviderSecrets(cfg ai.ProviderConfig) (ai.ProviderConfig, providerSec
|
||||
|
||||
meta.HasSecret = cfg.HasSecret || bundle.hasAny()
|
||||
meta.SecretRef = strings.TrimSpace(cfg.SecretRef)
|
||||
if meta.HasSecret && meta.SecretRef == "" && strings.TrimSpace(cfg.ID) != "" {
|
||||
if ref, err := secretstore.BuildRef(providerSecretKind, cfg.ID); err == nil {
|
||||
meta.SecretRef = ref
|
||||
}
|
||||
}
|
||||
if !meta.HasSecret {
|
||||
meta.SecretRef = ""
|
||||
}
|
||||
@@ -108,11 +105,7 @@ func mergeProviderSecrets(cfg ai.ProviderConfig, bundle providerSecretBundle) ai
|
||||
}
|
||||
|
||||
merged.HasSecret = cfg.HasSecret || bundle.hasAny()
|
||||
if merged.HasSecret && strings.TrimSpace(merged.SecretRef) == "" && strings.TrimSpace(merged.ID) != "" {
|
||||
if ref, err := secretstore.BuildRef(providerSecretKind, merged.ID); err == nil {
|
||||
merged.SecretRef = ref
|
||||
}
|
||||
}
|
||||
merged.SecretRef = ""
|
||||
if !merged.HasSecret {
|
||||
merged.SecretRef = ""
|
||||
}
|
||||
@@ -120,43 +113,73 @@ func mergeProviderSecrets(cfg ai.ProviderConfig, bundle providerSecretBundle) ai
|
||||
return merged
|
||||
}
|
||||
|
||||
func persistProviderSecretBundle(store secretstore.SecretStore, meta ai.ProviderConfig, bundle providerSecretBundle) (ai.ProviderConfig, error) {
|
||||
func toDailyProviderBundle(bundle providerSecretBundle) dailysecret.ProviderBundle {
|
||||
return dailysecret.ProviderBundle{
|
||||
APIKey: bundle.APIKey,
|
||||
SensitiveHeaders: cloneStringMap(bundle.SensitiveHeaders),
|
||||
}
|
||||
}
|
||||
|
||||
func fromDailyProviderBundle(bundle dailysecret.ProviderBundle) providerSecretBundle {
|
||||
return providerSecretBundle{
|
||||
APIKey: bundle.APIKey,
|
||||
SensitiveHeaders: cloneStringMap(bundle.SensitiveHeaders),
|
||||
}
|
||||
}
|
||||
|
||||
func persistProviderSecretBundle(store *dailysecret.Store, meta ai.ProviderConfig, bundle providerSecretBundle) (ai.ProviderConfig, error) {
|
||||
meta, _ = splitProviderSecrets(meta)
|
||||
if !bundle.hasAny() {
|
||||
meta.HasSecret = false
|
||||
meta.SecretRef = ""
|
||||
return meta, nil
|
||||
if store == nil {
|
||||
return meta, nil
|
||||
}
|
||||
return meta, store.DeleteAIProvider(meta.ID)
|
||||
}
|
||||
if store == nil {
|
||||
return meta, fmt.Errorf("secret store unavailable")
|
||||
return meta, fmt.Errorf("daily secret store unavailable")
|
||||
}
|
||||
if err := store.HealthCheck(); err != nil {
|
||||
if err := store.PutAIProvider(meta.ID, toDailyProviderBundle(bundle)); err != nil {
|
||||
return meta, err
|
||||
}
|
||||
|
||||
ref := strings.TrimSpace(meta.SecretRef)
|
||||
if ref == "" {
|
||||
var err error
|
||||
ref, err = secretstore.BuildRef(providerSecretKind, meta.ID)
|
||||
if err != nil {
|
||||
return meta, err
|
||||
}
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(bundle)
|
||||
if err != nil {
|
||||
return meta, fmt.Errorf("序列化 provider secret bundle 失败: %w", err)
|
||||
}
|
||||
if err := store.Put(ref, payload); err != nil {
|
||||
return meta, err
|
||||
}
|
||||
|
||||
meta.SecretRef = ref
|
||||
meta.SecretRef = ""
|
||||
meta.HasSecret = true
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func resolveProviderConfigSecrets(store secretstore.SecretStore, cfg ai.ProviderConfig) (ai.ProviderConfig, error) {
|
||||
func resolveProviderConfigSecrets(store *dailysecret.Store, cfg ai.ProviderConfig) (ai.ProviderConfig, error) {
|
||||
cfg = normalizeProviderConfig(cfg)
|
||||
meta, bundle := splitProviderSecrets(cfg)
|
||||
if bundle.hasAny() {
|
||||
return mergeProviderSecrets(meta, bundle), nil
|
||||
}
|
||||
if !meta.HasSecret {
|
||||
return meta, nil
|
||||
}
|
||||
if store == nil {
|
||||
return meta, fmt.Errorf("daily secret store unavailable")
|
||||
}
|
||||
stored, ok, err := store.GetAIProvider(meta.ID)
|
||||
if err != nil {
|
||||
return meta, err
|
||||
}
|
||||
if !ok {
|
||||
return meta, os.ErrNotExist
|
||||
}
|
||||
meta.SecretRef = ""
|
||||
return mergeProviderSecrets(meta, fromDailyProviderBundle(stored)), nil
|
||||
}
|
||||
|
||||
func (s *Service) persistProviderSecretBundle(meta ai.ProviderConfig, bundle providerSecretBundle) (ai.ProviderConfig, error) {
|
||||
return persistProviderSecretBundle(s.dailySecretStore(), meta, bundle)
|
||||
}
|
||||
|
||||
func (s *Service) resolveProviderConfigSecrets(cfg ai.ProviderConfig) (ai.ProviderConfig, error) {
|
||||
return resolveProviderConfigSecrets(s.dailySecretStore(), cfg)
|
||||
}
|
||||
|
||||
func resolveProviderConfigSecretsFromStore(store secretstore.SecretStore, cfg ai.ProviderConfig) (ai.ProviderConfig, error) {
|
||||
cfg = normalizeProviderConfig(cfg)
|
||||
meta, bundle := splitProviderSecrets(cfg)
|
||||
if bundle.hasAny() {
|
||||
@@ -191,14 +214,6 @@ func resolveProviderConfigSecrets(store secretstore.SecretStore, cfg ai.Provider
|
||||
return mergeProviderSecrets(meta, stored), nil
|
||||
}
|
||||
|
||||
func (s *Service) persistProviderSecretBundle(meta ai.ProviderConfig, bundle providerSecretBundle) (ai.ProviderConfig, error) {
|
||||
return persistProviderSecretBundle(s.secretStore, meta, bundle)
|
||||
}
|
||||
|
||||
func (s *Service) resolveProviderConfigSecrets(cfg ai.ProviderConfig) (ai.ProviderConfig, error) {
|
||||
return resolveProviderConfigSecrets(s.secretStore, cfg)
|
||||
}
|
||||
|
||||
func providerMetadataView(cfg ai.ProviderConfig) ai.ProviderConfig {
|
||||
meta, _ := splitProviderSecrets(normalizeProviderConfig(cfg))
|
||||
return meta
|
||||
|
||||
@@ -2,6 +2,7 @@ package aiservice
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -11,6 +12,17 @@ import (
|
||||
"GoNavi-Wails/internal/secretstore"
|
||||
)
|
||||
|
||||
func withTestAIGOOS(t *testing.T, goos string) {
|
||||
t.Helper()
|
||||
previous := aiRuntimeGOOS
|
||||
aiRuntimeGOOS = func() string {
|
||||
return goos
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
aiRuntimeGOOS = previous
|
||||
})
|
||||
}
|
||||
|
||||
func TestSplitProviderSecretsStripsAPIKeyAndSensitiveHeaders(t *testing.T) {
|
||||
input := ai.ProviderConfig{
|
||||
ID: "openai-main",
|
||||
@@ -41,28 +53,19 @@ func TestSplitProviderSecretsStripsAPIKeyAndSensitiveHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveProviderConfigSecretsRestoresStoredSecretBundle(t *testing.T) {
|
||||
store := newFakeProviderSecretStore()
|
||||
service := NewServiceWithSecretStore(store)
|
||||
ref, err := secretstore.BuildRef("ai-provider", "openai-main")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRef returned error: %v", err)
|
||||
}
|
||||
payload, err := json.Marshal(providerSecretBundle{
|
||||
service := NewServiceWithSecretStore(failOnUseSecretStore{})
|
||||
service.configDir = t.TempDir()
|
||||
if err := service.dailySecretStore().PutAIProvider("openai-main", toDailyProviderBundle(providerSecretBundle{
|
||||
APIKey: "sk-test",
|
||||
SensitiveHeaders: map[string]string{
|
||||
"Authorization": "Bearer test",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal returned error: %v", err)
|
||||
}
|
||||
if err := store.Put(ref, payload); err != nil {
|
||||
t.Fatalf("Put returned error: %v", err)
|
||||
})); err != nil {
|
||||
t.Fatalf("PutAIProvider returned error: %v", err)
|
||||
}
|
||||
|
||||
resolved, err := service.resolveProviderConfigSecrets(ai.ProviderConfig{
|
||||
ID: "openai-main",
|
||||
SecretRef: ref,
|
||||
HasSecret: true,
|
||||
Headers: map[string]string{
|
||||
"X-Team": "db",
|
||||
@@ -83,8 +86,7 @@ func TestResolveProviderConfigSecretsRestoresStoredSecretBundle(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoadConfigUsesPlaintextProviderSecretsWithoutSilentMigration(t *testing.T) {
|
||||
store := newFakeProviderSecretStore()
|
||||
service := NewServiceWithSecretStore(store)
|
||||
service := NewServiceWithSecretStore(failOnUseSecretStore{})
|
||||
service.configDir = t.TempDir()
|
||||
|
||||
legacy := aiConfig{
|
||||
@@ -134,12 +136,15 @@ func TestLoadConfigUsesPlaintextProviderSecretsWithoutSilentMigration(t *testing
|
||||
t.Fatalf("expected runtime provider to keep sensitive header, got %#v", service.providers[0].Headers)
|
||||
}
|
||||
|
||||
ref, err := secretstore.BuildRef("ai-provider", "openai-main")
|
||||
stored, ok, err := service.dailySecretStore().GetAIProvider("openai-main")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRef returned error: %v", err)
|
||||
t.Fatalf("GetAIProvider returned error: %v", err)
|
||||
}
|
||||
if _, err := store.Get(ref); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected startup load to avoid secret-store migration, got %v", err)
|
||||
if !ok {
|
||||
t.Fatal("expected startup load to migrate plaintext provider secret to daily store")
|
||||
}
|
||||
if stored.APIKey != "sk-test" || stored.SensitiveHeaders["Authorization"] != "Bearer test" {
|
||||
t.Fatalf("unexpected migrated provider bundle: %#v", stored)
|
||||
}
|
||||
|
||||
rewritten, err := os.ReadFile(configPath)
|
||||
@@ -147,17 +152,16 @@ func TestLoadConfigUsesPlaintextProviderSecretsWithoutSilentMigration(t *testing
|
||||
t.Fatalf("ReadFile returned error: %v", err)
|
||||
}
|
||||
text := string(rewritten)
|
||||
if !strings.Contains(text, "sk-test") {
|
||||
t.Fatalf("expected config file to remain unchanged, got %s", text)
|
||||
if strings.Contains(text, "sk-test") {
|
||||
t.Fatalf("expected config file to be rewritten secretless, got %s", text)
|
||||
}
|
||||
if !strings.Contains(text, "Bearer test") {
|
||||
t.Fatalf("expected config file to keep sensitive header, got %s", text)
|
||||
if strings.Contains(text, "Bearer test") {
|
||||
t.Fatalf("expected config file to remove sensitive header, got %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAISaveProviderKeepsLegacyPlaintextSecretAfterStartupLoad(t *testing.T) {
|
||||
store := newFakeProviderSecretStore()
|
||||
service := NewServiceWithSecretStore(store)
|
||||
service := NewServiceWithSecretStore(failOnUseSecretStore{})
|
||||
service.configDir = t.TempDir()
|
||||
|
||||
legacy := aiConfig{
|
||||
@@ -205,26 +209,20 @@ func TestAISaveProviderKeepsLegacyPlaintextSecretAfterStartupLoad(t *testing.T)
|
||||
t.Fatalf("expected runtime provider to keep legacy sensitive header, got %#v", service.providers[0].Headers)
|
||||
}
|
||||
|
||||
ref, err := secretstore.BuildRef("ai-provider", "openai-main")
|
||||
stored, ok, err := service.dailySecretStore().GetAIProvider("openai-main")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRef returned error: %v", err)
|
||||
t.Fatalf("GetAIProvider returned error: %v", err)
|
||||
}
|
||||
stored, err := store.Get(ref)
|
||||
if err != nil {
|
||||
t.Fatalf("expected save to persist provider secret bundle, got %v", err)
|
||||
if !ok {
|
||||
t.Fatal("expected provider secret to stay in daily store")
|
||||
}
|
||||
var bundle providerSecretBundle
|
||||
if err := json.Unmarshal(stored, &bundle); err != nil {
|
||||
t.Fatalf("Unmarshal returned error: %v", err)
|
||||
}
|
||||
if bundle.APIKey != "sk-test" {
|
||||
t.Fatalf("expected persisted apiKey, got %q", bundle.APIKey)
|
||||
if stored.APIKey != "sk-test" {
|
||||
t.Fatalf("expected persisted apiKey, got %q", stored.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAITestProviderUsesLegacyPlaintextSecretAfterStartupLoad(t *testing.T) {
|
||||
store := newFakeProviderSecretStore()
|
||||
service := NewServiceWithSecretStore(store)
|
||||
service := NewServiceWithSecretStore(failOnUseSecretStore{})
|
||||
service.configDir = t.TempDir()
|
||||
|
||||
legacy := aiConfig{
|
||||
@@ -269,8 +267,7 @@ func TestAITestProviderUsesLegacyPlaintextSecretAfterStartupLoad(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAISaveProviderPersistsSecretlessConfigAndReturnsSecretlessView(t *testing.T) {
|
||||
store := newFakeProviderSecretStore()
|
||||
service := NewServiceWithSecretStore(store)
|
||||
service := NewServiceWithSecretStore(failOnUseSecretStore{})
|
||||
service.configDir = t.TempDir()
|
||||
|
||||
err := service.AISaveProvider(ai.ProviderConfig{
|
||||
@@ -323,8 +320,7 @@ func TestAISaveProviderPersistsSecretlessConfigAndReturnsSecretlessView(t *testi
|
||||
}
|
||||
|
||||
func TestAISaveProviderKeepsExistingSecretWhenInputOmitsAPIKey(t *testing.T) {
|
||||
store := newFakeProviderSecretStore()
|
||||
service := NewServiceWithSecretStore(store)
|
||||
service := NewServiceWithSecretStore(failOnUseSecretStore{})
|
||||
service.configDir = t.TempDir()
|
||||
|
||||
if err := service.AISaveProvider(ai.ProviderConfig{
|
||||
@@ -377,8 +373,7 @@ func TestAISaveProviderKeepsExistingSecretWhenInputOmitsAPIKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAISaveProviderMergesStoredSensitiveHeadersWhenUpdatingOnlyAPIKey(t *testing.T) {
|
||||
store := newFakeProviderSecretStore()
|
||||
service := NewServiceWithSecretStore(store)
|
||||
service := NewServiceWithSecretStore(failOnUseSecretStore{})
|
||||
service.configDir = t.TempDir()
|
||||
|
||||
if err := service.AISaveProvider(ai.ProviderConfig{
|
||||
@@ -416,19 +411,18 @@ func TestAISaveProviderMergesStoredSensitiveHeadersWhenUpdatingOnlyAPIKey(t *tes
|
||||
t.Fatalf("expected existing sensitive header to be kept, got %#v", service.providers[0].Headers)
|
||||
}
|
||||
|
||||
stored, err := store.Get(service.providers[0].SecretRef)
|
||||
stored, ok, err := service.dailySecretStore().GetAIProvider("openai-main")
|
||||
if err != nil {
|
||||
t.Fatalf("expected merged secret bundle in store, got %v", err)
|
||||
t.Fatalf("GetAIProvider returned error: %v", err)
|
||||
}
|
||||
var bundle providerSecretBundle
|
||||
if err := json.Unmarshal(stored, &bundle); err != nil {
|
||||
t.Fatalf("Unmarshal returned error: %v", err)
|
||||
if !ok {
|
||||
t.Fatal("expected merged secret bundle in daily store")
|
||||
}
|
||||
if bundle.APIKey != "sk-updated" {
|
||||
t.Fatalf("expected store to keep updated apiKey, got %q", bundle.APIKey)
|
||||
if stored.APIKey != "sk-updated" {
|
||||
t.Fatalf("expected store to keep updated apiKey, got %q", stored.APIKey)
|
||||
}
|
||||
if bundle.SensitiveHeaders["Authorization"] != "Bearer original" {
|
||||
t.Fatalf("expected store to keep existing sensitive header, got %#v", bundle.SensitiveHeaders)
|
||||
if stored.SensitiveHeaders["Authorization"] != "Bearer original" {
|
||||
t.Fatalf("expected store to keep existing sensitive header, got %#v", stored.SensitiveHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,3 +457,23 @@ func (s *fakeProviderSecretStore) HealthCheck() error {
|
||||
}
|
||||
|
||||
var _ secretstore.SecretStore = (*fakeProviderSecretStore)(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)
|
||||
|
||||
@@ -198,8 +198,8 @@ func (s *Service) AISaveProvider(config ai.ProviderConfig) error {
|
||||
runtimeConfig = meta
|
||||
}
|
||||
|
||||
if !runtimeConfig.HasSecret && found && strings.TrimSpace(existing.SecretRef) != "" {
|
||||
if err := s.secretStore.Delete(existing.SecretRef); err != nil {
|
||||
if !runtimeConfig.HasSecret && found {
|
||||
if err := s.dailySecretStore().DeleteAIProvider(existing.ID); err != nil {
|
||||
return fmt.Errorf("删除 Provider secret 失败: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -960,7 +960,7 @@ func (s *Service) getActiveProvider() (provider.Provider, error) {
|
||||
// --- 配置持久化 ---
|
||||
|
||||
func (s *Service) loadConfig() {
|
||||
snapshot, err := NewProviderConfigStore(s.configDir, s.secretStore).LoadRuntime()
|
||||
snapshot, err := NewProviderConfigStore(s.configDir, s.secretStore).Load()
|
||||
if err != nil {
|
||||
logger.Error(err, "加载 AI 配置失败")
|
||||
return
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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{
|
||||
|
||||
223
internal/app/daily_secret_migration.go
Normal file
223
internal/app/daily_secret_migration.go
Normal 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{}
|
||||
}
|
||||
107
internal/app/daily_secret_persistence.go
Normal file
107
internal/app/daily_secret_persistence.go
Normal 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)
|
||||
}
|
||||
210
internal/app/darwin_daily_secret_migration_test.go
Normal file
210
internal/app/darwin_daily_secret_migration_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
181
internal/app/legacy_webkit_storage.go
Normal file
181
internal/app/legacy_webkit_storage.go
Normal 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))
|
||||
}
|
||||
183
internal/app/legacy_webkit_storage_test.go
Normal file
183
internal/app/legacy_webkit_storage_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
235
internal/dailysecret/store.go
Normal file
235
internal/dailysecret/store.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package dailysecret
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
fileName = "daily_secrets.json"
|
||||
schemaVersion = 1
|
||||
)
|
||||
|
||||
type ConnectionBundle struct {
|
||||
Password string `json:"password,omitempty"`
|
||||
SSHPassword string `json:"sshPassword,omitempty"`
|
||||
ProxyPassword string `json:"proxyPassword,omitempty"`
|
||||
HTTPTunnelPassword string `json:"httpTunnelPassword,omitempty"`
|
||||
MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"`
|
||||
MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"`
|
||||
OpaqueURI string `json:"opaqueURI,omitempty"`
|
||||
OpaqueDSN string `json:"opaqueDSN,omitempty"`
|
||||
}
|
||||
|
||||
func (b ConnectionBundle) HasAny() bool {
|
||||
return strings.TrimSpace(b.Password) != "" ||
|
||||
strings.TrimSpace(b.SSHPassword) != "" ||
|
||||
strings.TrimSpace(b.ProxyPassword) != "" ||
|
||||
strings.TrimSpace(b.HTTPTunnelPassword) != "" ||
|
||||
strings.TrimSpace(b.MySQLReplicaPassword) != "" ||
|
||||
strings.TrimSpace(b.MongoReplicaPassword) != "" ||
|
||||
strings.TrimSpace(b.OpaqueURI) != "" ||
|
||||
strings.TrimSpace(b.OpaqueDSN) != ""
|
||||
}
|
||||
|
||||
type GlobalProxyBundle struct {
|
||||
Password string `json:"password,omitempty"`
|
||||
}
|
||||
|
||||
func (b GlobalProxyBundle) HasAny() bool {
|
||||
return strings.TrimSpace(b.Password) != ""
|
||||
}
|
||||
|
||||
type ProviderBundle struct {
|
||||
APIKey string `json:"apiKey,omitempty"`
|
||||
SensitiveHeaders map[string]string `json:"sensitiveHeaders,omitempty"`
|
||||
}
|
||||
|
||||
func (b ProviderBundle) HasAny() bool {
|
||||
return strings.TrimSpace(b.APIKey) != "" || len(b.SensitiveHeaders) > 0
|
||||
}
|
||||
|
||||
type File struct {
|
||||
SchemaVersion int `json:"schemaVersion,omitempty"`
|
||||
Connections map[string]ConnectionBundle `json:"connections,omitempty"`
|
||||
GlobalProxy *GlobalProxyBundle `json:"globalProxy,omitempty"`
|
||||
AIProviders map[string]ProviderBundle `json:"aiProviders,omitempty"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
root string
|
||||
}
|
||||
|
||||
func NewStore(root string) *Store {
|
||||
return &Store{root: strings.TrimSpace(root)}
|
||||
}
|
||||
|
||||
func (s *Store) Path() string {
|
||||
return filepath.Join(s.root, fileName)
|
||||
}
|
||||
|
||||
func (s *Store) Load() (File, error) {
|
||||
if strings.TrimSpace(s.root) == "" {
|
||||
return File{SchemaVersion: schemaVersion}, nil
|
||||
}
|
||||
data, err := os.ReadFile(s.Path())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return File{SchemaVersion: schemaVersion}, nil
|
||||
}
|
||||
return File{}, err
|
||||
}
|
||||
var file File
|
||||
if err := json.Unmarshal(data, &file); err != nil {
|
||||
return File{}, err
|
||||
}
|
||||
if file.SchemaVersion == 0 {
|
||||
file.SchemaVersion = schemaVersion
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func (s *Store) Save(file File) error {
|
||||
if strings.TrimSpace(s.root) == "" {
|
||||
return nil
|
||||
}
|
||||
file.SchemaVersion = schemaVersion
|
||||
if len(file.Connections) == 0 {
|
||||
file.Connections = nil
|
||||
}
|
||||
if file.GlobalProxy != nil && !file.GlobalProxy.HasAny() {
|
||||
file.GlobalProxy = nil
|
||||
}
|
||||
if len(file.AIProviders) == 0 {
|
||||
file.AIProviders = nil
|
||||
}
|
||||
if err := os.MkdirAll(s.root, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := json.MarshalIndent(file, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(s.Path(), payload, 0o644)
|
||||
}
|
||||
|
||||
func (s *Store) GetConnection(id string) (ConnectionBundle, bool, error) {
|
||||
file, err := s.Load()
|
||||
if err != nil {
|
||||
return ConnectionBundle{}, false, err
|
||||
}
|
||||
bundle, ok := file.Connections[strings.TrimSpace(id)]
|
||||
return bundle, ok, nil
|
||||
}
|
||||
|
||||
func (s *Store) PutConnection(id string, bundle ConnectionBundle) error {
|
||||
file, err := s.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !bundle.HasAny() {
|
||||
return s.deleteConnectionFromFile(file, id)
|
||||
}
|
||||
if file.Connections == nil {
|
||||
file.Connections = make(map[string]ConnectionBundle)
|
||||
}
|
||||
file.Connections[strings.TrimSpace(id)] = bundle
|
||||
return s.Save(file)
|
||||
}
|
||||
|
||||
func (s *Store) DeleteConnection(id string) error {
|
||||
file, err := s.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.deleteConnectionFromFile(file, id)
|
||||
}
|
||||
|
||||
func (s *Store) deleteConnectionFromFile(file File, id string) error {
|
||||
if len(file.Connections) != 0 {
|
||||
delete(file.Connections, strings.TrimSpace(id))
|
||||
}
|
||||
return s.Save(file)
|
||||
}
|
||||
|
||||
func (s *Store) GetGlobalProxy() (GlobalProxyBundle, bool, error) {
|
||||
file, err := s.Load()
|
||||
if err != nil {
|
||||
return GlobalProxyBundle{}, false, err
|
||||
}
|
||||
if file.GlobalProxy == nil {
|
||||
return GlobalProxyBundle{}, false, nil
|
||||
}
|
||||
return *file.GlobalProxy, true, nil
|
||||
}
|
||||
|
||||
func (s *Store) PutGlobalProxy(bundle GlobalProxyBundle) error {
|
||||
file, err := s.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !bundle.HasAny() {
|
||||
file.GlobalProxy = nil
|
||||
return s.Save(file)
|
||||
}
|
||||
copyBundle := bundle
|
||||
file.GlobalProxy = ©Bundle
|
||||
return s.Save(file)
|
||||
}
|
||||
|
||||
func (s *Store) DeleteGlobalProxy() error {
|
||||
file, err := s.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file.GlobalProxy = nil
|
||||
return s.Save(file)
|
||||
}
|
||||
|
||||
func (s *Store) GetAIProvider(id string) (ProviderBundle, bool, error) {
|
||||
file, err := s.Load()
|
||||
if err != nil {
|
||||
return ProviderBundle{}, false, err
|
||||
}
|
||||
bundle, ok := file.AIProviders[strings.TrimSpace(id)]
|
||||
return bundle, ok, nil
|
||||
}
|
||||
|
||||
func (s *Store) PutAIProvider(id string, bundle ProviderBundle) error {
|
||||
file, err := s.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !bundle.HasAny() {
|
||||
return s.deleteAIProviderFromFile(file, id)
|
||||
}
|
||||
if file.AIProviders == nil {
|
||||
file.AIProviders = make(map[string]ProviderBundle)
|
||||
}
|
||||
if len(bundle.SensitiveHeaders) > 0 {
|
||||
cloned := make(map[string]string, len(bundle.SensitiveHeaders))
|
||||
for key, value := range bundle.SensitiveHeaders {
|
||||
cloned[key] = value
|
||||
}
|
||||
bundle.SensitiveHeaders = cloned
|
||||
}
|
||||
file.AIProviders[strings.TrimSpace(id)] = bundle
|
||||
return s.Save(file)
|
||||
}
|
||||
|
||||
func (s *Store) DeleteAIProvider(id string) error {
|
||||
file, err := s.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.deleteAIProviderFromFile(file, id)
|
||||
}
|
||||
|
||||
func (s *Store) deleteAIProviderFromFile(file File, id string) error {
|
||||
if len(file.AIProviders) != 0 {
|
||||
delete(file.AIProviders, strings.TrimSpace(id))
|
||||
}
|
||||
return s.Save(file)
|
||||
}
|
||||
104
internal/dailysecret/store_test.go
Normal file
104
internal/dailysecret/store_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package dailysecret
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestStorePutGetDeleteConnectionSecret(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store := NewStore(root)
|
||||
|
||||
bundle := ConnectionBundle{
|
||||
Password: "postgres-secret",
|
||||
OpaqueDSN: "postgres://user:pass@db.local/app",
|
||||
SSHPassword: "ssh-secret",
|
||||
}
|
||||
if err := store.PutConnection("conn-1", bundle); err != nil {
|
||||
t.Fatalf("PutConnection returned error: %v", err)
|
||||
}
|
||||
|
||||
got, ok, err := store.GetConnection("conn-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetConnection returned error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected connection bundle to exist")
|
||||
}
|
||||
if got.Password != "postgres-secret" || got.OpaqueDSN != bundle.OpaqueDSN || got.SSHPassword != "ssh-secret" {
|
||||
t.Fatalf("unexpected bundle: %#v", got)
|
||||
}
|
||||
|
||||
if err := store.DeleteConnection("conn-1"); err != nil {
|
||||
t.Fatalf("DeleteConnection returned error: %v", err)
|
||||
}
|
||||
got, ok, err = store.GetConnection("conn-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetConnection after delete returned error: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("expected missing connection bundle after delete, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorePutGetDeleteGlobalProxySecret(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store := NewStore(root)
|
||||
|
||||
if err := store.PutGlobalProxy(GlobalProxyBundle{Password: "proxy-secret"}); err != nil {
|
||||
t.Fatalf("PutGlobalProxy returned error: %v", err)
|
||||
}
|
||||
|
||||
got, ok, err := store.GetGlobalProxy()
|
||||
if err != nil {
|
||||
t.Fatalf("GetGlobalProxy returned error: %v", err)
|
||||
}
|
||||
if !ok || got.Password != "proxy-secret" {
|
||||
t.Fatalf("unexpected global proxy bundle: %#v ok=%v", got, ok)
|
||||
}
|
||||
|
||||
if err := store.DeleteGlobalProxy(); err != nil {
|
||||
t.Fatalf("DeleteGlobalProxy returned error: %v", err)
|
||||
}
|
||||
_, ok, err = store.GetGlobalProxy()
|
||||
if err != nil {
|
||||
t.Fatalf("GetGlobalProxy after delete returned error: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("expected global proxy bundle to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorePutGetDeleteAIProviderSecret(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store := NewStore(root)
|
||||
|
||||
bundle := ProviderBundle{
|
||||
APIKey: "sk-test",
|
||||
SensitiveHeaders: map[string]string{
|
||||
"Authorization": "Bearer test",
|
||||
},
|
||||
}
|
||||
if err := store.PutAIProvider("openai-main", bundle); err != nil {
|
||||
t.Fatalf("PutAIProvider returned error: %v", err)
|
||||
}
|
||||
|
||||
got, ok, err := store.GetAIProvider("openai-main")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAIProvider returned error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected provider bundle to exist")
|
||||
}
|
||||
if got.APIKey != "sk-test" || got.SensitiveHeaders["Authorization"] != "Bearer test" {
|
||||
t.Fatalf("unexpected provider bundle: %#v", got)
|
||||
}
|
||||
|
||||
if err := store.DeleteAIProvider("openai-main"); err != nil {
|
||||
t.Fatalf("DeleteAIProvider returned error: %v", err)
|
||||
}
|
||||
_, ok, err = store.GetAIProvider("openai-main")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAIProvider after delete returned error: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("expected provider bundle to be deleted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user