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

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

View File

@@ -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) != ""
}

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 = &copyBundle
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)
}

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