From de251280e04ec82a6f4040fa616382412419f004 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Hermann?= Date: Sat, 4 Apr 2026 14:24:46 +0200 Subject: [PATCH] Improve TUI store secret configuration --- cmd/cloudstic/cmd_tui_profile_form.go | 36 ++ cmd/cloudstic/cmd_tui_store_form.go | 615 +++++++++++++++++++++++++- cmd/cloudstic/cmd_tui_test.go | 196 ++++++++ 3 files changed, 834 insertions(+), 13 deletions(-) diff --git a/cmd/cloudstic/cmd_tui_profile_form.go b/cmd/cloudstic/cmd_tui_profile_form.go index 5a4ac5d..ce94d60 100644 --- a/cmd/cloudstic/cmd_tui_profile_form.go +++ b/cmd/cloudstic/cmd_tui_profile_form.go @@ -486,6 +486,16 @@ func (s *tuiSession) runStoreModal(existingName string, editing bool) (string, b if err != nil { return "", false, err } + if spec, ok := modal.wantsEditSecret(input); ok { + ref, canceled, err := s.runSecretRefModal(modal.storeName(), spec, modal.fieldValue(spec.FieldKey)) + if err != nil { + return "", false, err + } + if !canceled { + modal.setFieldValue(spec.FieldKey, ref) + } + continue + } done, name, err := modal.Handle(input) if err != nil { return "", false, err @@ -500,6 +510,32 @@ func (s *tuiSession) runStoreModal(existingName string, editing bool) (string, b } } +func (s *tuiSession) runSecretRefModal(storeName string, spec tuiSecretFieldSpec, existingRef string) (string, bool, error) { + modal := newTUISecretRefModal(storeName, spec, existingRef) + for { + view := modal.View() + s.dashboard.Modal = &view + if err := s.render(); err != nil { + return "", false, err + } + input, err := readTUIModalInput(s.r.lineReader()) + if err != nil { + return "", false, err + } + done, ref, err := modal.Handle(input) + if err != nil { + return "", false, err + } + if !done { + continue + } + if ref == "" { + return "", true, nil + } + return ref, false, nil + } +} + func (s *tuiSession) runDeleteModal(ctx context.Context, profile tui.ProfileCard) error { modal := tui.Modal{ Kind: tui.ModalKindConfirm, diff --git a/cmd/cloudstic/cmd_tui_store_form.go b/cmd/cloudstic/cmd_tui_store_form.go index 72886a7..d495d74 100644 --- a/cmd/cloudstic/cmd_tui_store_form.go +++ b/cmd/cloudstic/cmd_tui_store_form.go @@ -1,11 +1,13 @@ package main import ( + "context" "fmt" "slices" "strings" cloudstic "github.com/cloudstic/cli" + "github.com/cloudstic/cli/internal/secretref" "github.com/cloudstic/cli/internal/tui" "github.com/cloudstic/cli/internal/ui" ) @@ -15,6 +17,24 @@ type tuiStoreConfig struct { Value string } +type tuiStoreEncryptionMode string + +const ( + tuiStoreEncryptionNone tuiStoreEncryptionMode = "none" + tuiStoreEncryptionPassword tuiStoreEncryptionMode = "password" + tuiStoreEncryptionPlatform tuiStoreEncryptionMode = "platform" + tuiStoreEncryptionKMS tuiStoreEncryptionMode = "kms" +) + +type tuiSecretFieldSpec struct { + FieldKey string + SecretLabel string + DefaultEnvName string + DefaultAccount string +} + +var tuiSecretResolver = profileSecretResolver + func newTUIStoreConfig(raw string) tuiStoreConfig { parts, err := parseStoreURI(raw) if err != nil { @@ -108,6 +128,19 @@ func (s tuiStoreConfig) ExampleText() string { } } +func newTUIStoreEncryptionMode(existing cloudstic.ProfileStore) tuiStoreEncryptionMode { + switch { + case existing.KMSKeyARN != "": + return tuiStoreEncryptionKMS + case existing.EncryptionKeySecret != "": + return tuiStoreEncryptionPlatform + case existing.PasswordSecret != "": + return tuiStoreEncryptionPassword + default: + return tuiStoreEncryptionNone + } +} + type tuiStoreModal struct { profilesFile string cfg *cloudstic.ProfilesConfig @@ -118,6 +151,13 @@ type tuiStoreModal struct { var tuiStoreTypes = []string{"local", "s3", "b2", "sftp"} +var tuiStoreEncryptionOptions = []string{ + string(tuiStoreEncryptionNone), + string(tuiStoreEncryptionPassword), + string(tuiStoreEncryptionPlatform), + string(tuiStoreEncryptionKMS), +} + func newTUIStoreModal(profilesFile, existingName string, editing bool) (*tuiStoreModal, error) { cfg, err := loadProfilesOrInit(profilesFile) if err != nil { @@ -134,6 +174,7 @@ func newTUIStoreModal(profilesFile, existingName string, editing bool) (*tuiStor } } storeCfg := newTUIStoreConfig(existing.URI) + encryptionMode := newTUIStoreEncryptionMode(existing) m := &tuiStoreModal{ profilesFile: profilesFile, cfg: cfg, @@ -150,10 +191,23 @@ func newTUIStoreModal(profilesFile, existingName string, editing bool) (*tuiStor {Key: "name", Label: "Name", Kind: tui.ModalFieldText, Value: existingName, Required: true, Disabled: editing}, {Key: "store_type", Label: "Store Type", Kind: tui.ModalFieldSelect, Value: firstNonEmpty(storeCfg.Type, "local"), Options: append([]string{}, tuiStoreTypes...), Required: true}, {Key: "store_value", Label: storeCfg.DetailLabel(), Kind: tui.ModalFieldText, Value: storeCfg.Value, Required: true}, + {Key: "s3_region", Label: "S3 Region", Kind: tui.ModalFieldText, Value: existing.S3Region}, + {Key: "s3_profile", Label: "S3 Profile", Kind: tui.ModalFieldText, Value: existing.S3Profile}, + {Key: "s3_endpoint", Label: "S3 Endpoint", Kind: tui.ModalFieldText, Value: existing.S3Endpoint}, + {Key: "s3_access_key_secret", Label: "Access Key Ref", Kind: tui.ModalFieldText, Value: existing.S3AccessKeySecret}, + {Key: "s3_secret_key_secret", Label: "Secret Key Ref", Kind: tui.ModalFieldText, Value: existing.S3SecretKeySecret}, + {Key: "sftp_password_secret", Label: "Password Ref", Kind: tui.ModalFieldText, Value: existing.StoreSFTPPasswordSecret}, + {Key: "sftp_key_secret", Label: "Key Ref", Kind: tui.ModalFieldText, Value: existing.StoreSFTPKeySecret}, + {Key: "encryption_mode", Label: "Encryption", Kind: tui.ModalFieldSelect, Value: string(encryptionMode), Options: append([]string{}, tuiStoreEncryptionOptions...), Required: true}, + {Key: "password_secret", Label: "Password Ref", Kind: tui.ModalFieldText, Value: existing.PasswordSecret}, + {Key: "encryption_key_secret", Label: "Platform Key Ref", Kind: tui.ModalFieldText, Value: existing.EncryptionKeySecret}, + {Key: "kms_key_arn", Label: "KMS Key ARN", Kind: tui.ModalFieldText, Value: existing.KMSKeyARN}, + {Key: "kms_region", Label: "KMS Region", Kind: tui.ModalFieldText, Value: firstNonEmpty(existing.KMSRegion, "us-east-1")}, + {Key: "kms_endpoint", Label: "KMS Endpoint", Kind: tui.ModalFieldText, Value: existing.KMSEndpoint}, }, }, } - m.updateStoreFieldMetadata() + m.rebuildDerivedFields() m.selectFirstEditableField() return m, nil } @@ -162,7 +216,8 @@ func (m *tuiStoreModal) View() tui.Modal { view := m.modal store := m.currentStore() view.Subtitle = store.Description(m.editing, storeUsageCount(m.cfg, m.originalName)) - view.Message = storeFieldExamples(m.selectedFieldKey(), store) + view.Message = storeFieldHelp(m.selectedFieldKey(), store, m.currentEncryptionMode()) + view.Fields, view.Selected = visibleStoreModalFields(m.modal.Fields, m.modal.Selected) return view } @@ -199,6 +254,14 @@ func (m *tuiStoreModal) Handle(input tuiModalInput) (bool, string, error) { return false, "", nil } +func (m *tuiStoreModal) wantsEditSecret(input tuiModalInput) (tuiSecretFieldSpec, bool) { + if input.Kind != tuiModalInputText || !strings.EqualFold(input.Text, "e") { + return tuiSecretFieldSpec{}, false + } + spec, ok := tuiSecretFieldSpecForKey(m.selectedFieldKey()) + return spec, ok +} + func (m *tuiStoreModal) selectFirstEditableField() { for i, field := range m.modal.Fields { if !field.Disabled { @@ -247,8 +310,9 @@ func (m *tuiStoreModal) cycleField(delta int) { } field.Value = field.Options[idx] m.clearError() - if field.Key == "store_type" { - m.updateStoreFieldMetadata() + switch field.Key { + case "store_type", "encryption_mode": + m.rebuildDerivedFields() } } @@ -294,17 +358,120 @@ func (m *tuiStoreModal) submit() (string, error) { if _, err := parseStoreURI(uri); err != nil { return "", fieldError("store_value", fmt.Sprintf("invalid store: %v", err)) } - store := cloudstic.ProfileStore{URI: uri} + + store := cloudstic.ProfileStore{} if m.editing { store = m.cfg.Stores[m.originalName] - store.URI = uri } + store.URI = uri + m.applyConnectionFields(&store) + if err := m.applyEncryptionFields(&store); err != nil { + return "", err + } + if err := tuiServiceFactory(nil, m.profilesFile, nil).SaveStore(m.profilesFile, name, store); err != nil { return "", err } return name, nil } +func (m *tuiStoreModal) applyConnectionFields(store *cloudstic.ProfileStore) { + storeType := m.currentStore().Type + switch storeType { + case "s3", "b2": + store.S3Region = m.textFieldValue("s3_region") + store.S3Profile = m.textFieldValue("s3_profile") + store.S3Endpoint = m.textFieldValue("s3_endpoint") + store.S3AccessKeySecret = m.textFieldValue("s3_access_key_secret") + store.S3SecretKeySecret = m.textFieldValue("s3_secret_key_secret") + case "local": + store.S3Region = "" + store.S3Profile = "" + store.S3Endpoint = "" + store.S3AccessKeySecret = "" + store.S3SecretKeySecret = "" + } + if storeType != "s3" && storeType != "b2" { + store.S3Region = "" + store.S3Profile = "" + store.S3Endpoint = "" + store.S3AccessKeySecret = "" + store.S3SecretKeySecret = "" + } + if storeType == "sftp" { + store.StoreSFTPPasswordSecret = m.textFieldValue("sftp_password_secret") + store.StoreSFTPKeySecret = m.textFieldValue("sftp_key_secret") + } else { + store.StoreSFTPPasswordSecret = "" + store.StoreSFTPKeySecret = "" + } +} + +func (m *tuiStoreModal) applyEncryptionFields(store *cloudstic.ProfileStore) error { + mode := m.currentEncryptionMode() + passwordRef := m.textFieldValue("password_secret") + platformRef := m.textFieldValue("encryption_key_secret") + kmsKeyARN := m.textFieldValue("kms_key_arn") + kmsRegion := m.textFieldValue("kms_region") + kmsEndpoint := m.textFieldValue("kms_endpoint") + + for _, refField := range []struct { + key string + value string + }{ + {key: "s3_access_key_secret", value: m.textFieldValue("s3_access_key_secret")}, + {key: "s3_secret_key_secret", value: m.textFieldValue("s3_secret_key_secret")}, + {key: "sftp_password_secret", value: m.textFieldValue("sftp_password_secret")}, + {key: "sftp_key_secret", value: m.textFieldValue("sftp_key_secret")}, + {key: "password_secret", value: passwordRef}, + {key: "encryption_key_secret", value: platformRef}, + } { + if err := validateSecretRefField(refField.key, refField.value); err != nil { + return err + } + } + + store.PasswordSecret = "" + store.EncryptionKeySecret = "" + store.RecoveryKeySecret = "" + store.KMSKeyARN = "" + store.KMSRegion = "" + store.KMSEndpoint = "" + + switch mode { + case tuiStoreEncryptionNone: + return nil + case tuiStoreEncryptionPassword: + if passwordRef == "" { + return fieldError("password_secret", "password secret reference is required") + } + store.PasswordSecret = passwordRef + case tuiStoreEncryptionPlatform: + if platformRef == "" { + return fieldError("encryption_key_secret", "platform key secret reference is required") + } + store.EncryptionKeySecret = platformRef + case tuiStoreEncryptionKMS: + if kmsKeyARN == "" { + return fieldError("kms_key_arn", "KMS key ARN is required") + } + store.KMSKeyARN = kmsKeyARN + store.KMSRegion = firstNonEmpty(kmsRegion, "us-east-1") + store.KMSEndpoint = kmsEndpoint + } + return nil +} + +func validateSecretRefField(key, value string) error { + if value == "" { + return nil + } + if _, err := secretref.Parse(value); err != nil { + return fieldError(key, fmt.Sprintf("invalid secret reference: %v", err)) + } + return nil +} + func (m *tuiStoreModal) currentStore() tuiStoreConfig { return tuiStoreConfig{ Type: firstNonEmpty(m.fieldValue("store_type"), "local"), @@ -312,6 +479,16 @@ func (m *tuiStoreModal) currentStore() tuiStoreConfig { } } +func (m *tuiStoreModal) currentEncryptionMode() tuiStoreEncryptionMode { + return tuiStoreEncryptionMode(firstNonEmpty(m.fieldValue("encryption_mode"), string(tuiStoreEncryptionNone))) +} + +func (m *tuiStoreModal) rebuildDerivedFields() { + m.updateStoreFieldMetadata() + m.updateConnectionFields() + m.updateEncryptionFields() +} + func (m *tuiStoreModal) updateStoreFieldMetadata() { field := m.fieldByKey("store_value") if field == nil { @@ -321,6 +498,59 @@ func (m *tuiStoreModal) updateStoreFieldMetadata() { field.Required = true } +func (m *tuiStoreModal) updateConnectionFields() { + storeType := m.currentStore().Type + s3Fields := []string{"s3_region", "s3_profile", "s3_endpoint", "s3_access_key_secret", "s3_secret_key_secret"} + sftpFields := []string{"sftp_password_secret", "sftp_key_secret"} + enableS3 := storeType == "s3" || storeType == "b2" + for _, key := range s3Fields { + if field := m.fieldByKey(key); field != nil { + field.Disabled = !enableS3 + field.Required = false + } + } + enableSFTP := storeType == "sftp" + for _, key := range sftpFields { + if field := m.fieldByKey(key); field != nil { + field.Disabled = !enableSFTP + field.Required = false + } + } +} + +func (m *tuiStoreModal) updateEncryptionFields() { + mode := m.currentEncryptionMode() + for _, key := range []string{"password_secret", "encryption_key_secret", "kms_key_arn", "kms_region", "kms_endpoint"} { + if field := m.fieldByKey(key); field != nil { + field.Disabled = true + field.Required = false + } + } + switch mode { + case tuiStoreEncryptionPassword: + if field := m.fieldByKey("password_secret"); field != nil { + field.Disabled = false + field.Required = true + } + case tuiStoreEncryptionPlatform: + if field := m.fieldByKey("encryption_key_secret"); field != nil { + field.Disabled = false + field.Required = true + } + case tuiStoreEncryptionKMS: + if field := m.fieldByKey("kms_key_arn"); field != nil { + field.Disabled = false + field.Required = true + } + if field := m.fieldByKey("kms_region"); field != nil { + field.Disabled = false + } + if field := m.fieldByKey("kms_endpoint"); field != nil { + field.Disabled = false + } + } +} + func (m *tuiStoreModal) fieldByKey(key string) *tui.ModalField { for i := range m.modal.Fields { if m.modal.Fields[i].Key == key { @@ -338,6 +568,18 @@ func (m *tuiStoreModal) fieldValue(key string) string { return field.Value } +func (m *tuiStoreModal) setFieldValue(key, value string) { + field := m.fieldByKey(key) + if field == nil { + return + } + field.Value = value +} + +func (m *tuiStoreModal) textFieldValue(key string) string { + return strings.TrimSpace(m.fieldValue(key)) +} + func (m *tuiStoreModal) selectedFieldKey() string { if m.modal.Selected < 0 || m.modal.Selected >= len(m.modal.Fields) { return "" @@ -350,15 +592,51 @@ func (m *tuiStoreModal) clearError() { m.modal.ErrorField = "" } -func storeFieldExamples(selectedField string, store tuiStoreConfig) []string { - if selectedField != "store_value" { - return nil +func storeFieldHelp(selectedField string, store tuiStoreConfig, mode tuiStoreEncryptionMode) []string { + switch selectedField { + case "store_value": + example := store.ExampleText() + if example == "" { + return nil + } + return []string{fmt.Sprintf("%s%s%s", ui.Dim, example, ui.Reset)} + case "s3_access_key_secret", "s3_secret_key_secret", "sftp_password_secret", "sftp_key_secret", "password_secret", "encryption_key_secret": + return []string{fmt.Sprintf("%sType e to configure secret storage.%s", ui.Dim, ui.Reset)} + case "kms_key_arn": + return []string{fmt.Sprintf("%sExample: arn:aws:kms:us-east-1:123456789012:key/abcd...%s", ui.Dim, ui.Reset)} + case "kms_region": + if mode != tuiStoreEncryptionKMS { + return nil + } + return []string{fmt.Sprintf("%sExample: us-east-1%s", ui.Dim, ui.Reset)} + case "kms_endpoint": + if mode != tuiStoreEncryptionKMS { + return nil + } + return []string{fmt.Sprintf("%sExample: https://kms.example.com%s", ui.Dim, ui.Reset)} } - example := store.ExampleText() - if example == "" { - return nil + return nil +} + +func visibleStoreModalFields(fields []tui.ModalField, selected int) ([]tui.ModalField, int) { + visible := make([]tui.ModalField, 0, len(fields)) + selectedVisible := 0 + for i, field := range fields { + if field.Disabled && !field.Required { + continue + } + if i == selected { + selectedVisible = len(visible) + } + visible = append(visible, field) } - return []string{fmt.Sprintf("%s%s%s", ui.Dim, example, ui.Reset)} + if len(visible) == 0 { + return nil, 0 + } + if selectedVisible >= len(visible) { + selectedVisible = len(visible) - 1 + } + return visible, selectedVisible } func storeModalTitle(editing bool) string { @@ -380,3 +658,314 @@ func storeUsageCount(cfg *cloudstic.ProfilesConfig, storeName string) int { } return count } + +func (m *tuiStoreModal) storeName() string { + name := strings.TrimSpace(m.fieldValue("name")) + if name != "" { + return name + } + if m.originalName != "" { + return m.originalName + } + return "store" +} + +func tuiSecretFieldSpecForKey(key string) (tuiSecretFieldSpec, bool) { + specs := map[string]tuiSecretFieldSpec{ + "s3_access_key_secret": { + FieldKey: "s3_access_key_secret", + SecretLabel: "S3 access key", + DefaultEnvName: "AWS_ACCESS_KEY_ID", + DefaultAccount: "s3-access-key", + }, + "s3_secret_key_secret": { + FieldKey: "s3_secret_key_secret", + SecretLabel: "S3 secret key", + DefaultEnvName: "AWS_SECRET_ACCESS_KEY", + DefaultAccount: "s3-secret-key", + }, + "sftp_password_secret": { + FieldKey: "sftp_password_secret", + SecretLabel: "SFTP password", + DefaultEnvName: "CLOUDSTIC_STORE_SFTP_PASSWORD", + DefaultAccount: "store-sftp-password", + }, + "sftp_key_secret": { + FieldKey: "sftp_key_secret", + SecretLabel: "SFTP key path", + DefaultEnvName: "CLOUDSTIC_STORE_SFTP_KEY", + DefaultAccount: "store-sftp-key", + }, + "password_secret": { + FieldKey: "password_secret", + SecretLabel: "repository password", + DefaultEnvName: "CLOUDSTIC_PASSWORD", + DefaultAccount: "password", + }, + "encryption_key_secret": { + FieldKey: "encryption_key_secret", + SecretLabel: "platform key", + DefaultEnvName: "CLOUDSTIC_ENCRYPTION_KEY", + DefaultAccount: "encryption-key", + }, + } + spec, ok := specs[key] + return spec, ok +} + +type tuiSecretRefModal struct { + storeName string + spec tuiSecretFieldSpec + existingRef string + resolver *secretref.Resolver + backendByRef map[string]secretref.WritableBackend + modal tui.Modal +} + +func newTUISecretRefModal(storeName string, spec tuiSecretFieldSpec, existingRef string) *tuiSecretRefModal { + if storeName == "" { + storeName = "store" + } + resolver := tuiSecretResolver + if resolver == nil { + resolver = secretref.NewDefaultResolver() + } + backends := resolver.WritableBackends() + options := []string{"env"} + backendByRef := map[string]secretref.WritableBackend{} + for _, backend := range backends { + options = append(options, backend.Scheme()) + backendByRef[backend.Scheme()] = backend + } + storage, refValue := initialSecretRefSelection(spec, existingRef) + m := &tuiSecretRefModal{ + storeName: storeName, + spec: spec, + existingRef: existingRef, + resolver: resolver, + backendByRef: backendByRef, + modal: tui.Modal{ + Kind: tui.ModalKindProfileForm, + Title: "Configure Secret", + Subtitle: fmt.Sprintf("Choose where %s should be stored.", spec.SecretLabel), + Hint: "↑/↓ or Tab to move, ←/→ to change selections, Enter to save, Esc to cancel.", + SubmitLabel: "Save", + CancelLabel: "Cancel", + Fields: []tui.ModalField{ + {Key: "storage", Label: "Storage", Kind: tui.ModalFieldSelect, Value: storage, Options: options, Required: true}, + {Key: "ref", Label: "Env Var", Kind: tui.ModalFieldText, Value: refValue, Required: true}, + {Key: "value", Label: "Secret Value", Kind: tui.ModalFieldText, Value: ""}, + }, + }, + } + m.updateFields() + return m +} + +func initialSecretRefSelection(spec tuiSecretFieldSpec, existingRef string) (string, string) { + if existingRef != "" { + if ref, err := secretref.Parse(existingRef); err == nil { + if ref.Scheme == "env" { + return "env", strings.TrimLeft(ref.Path, "/") + } + return ref.Scheme, existingRef + } + } + return "env", spec.DefaultEnvName +} + +func (m *tuiSecretRefModal) View() tui.Modal { + view := m.modal + view.Fields, view.Selected = visibleStoreModalFields(m.modal.Fields, m.modal.Selected) + view.Message = secretRefHelp(m.currentStorage(), m.spec) + return view +} + +func (m *tuiSecretRefModal) Handle(input tuiModalInput) (bool, string, error) { + switch input.Kind { + case tuiModalInputEscape: + return true, "", nil + case tuiModalInputUp: + m.moveField(-1) + case tuiModalInputDown, tuiModalInputTab: + m.moveField(1) + case tuiModalInputLeft: + m.cycleField(-1) + case tuiModalInputRight: + m.cycleField(1) + case tuiModalInputBackspace: + m.backspaceField() + case tuiModalInputText: + m.appendField(input.Text) + case tuiModalInputEnter: + ref, err := m.submit(context.Background()) + if err != nil { + if fieldErr, ok := err.(*tuiFieldError); ok { + m.modal.ErrorField = fieldErr.Field + m.modal.Error = fieldErr.Message + } else { + m.modal.Error = err.Error() + m.modal.ErrorField = "" + } + return false, "", nil + } + return true, ref, nil + } + return false, "", nil +} + +func (m *tuiSecretRefModal) moveField(delta int) { + if len(m.modal.Fields) == 0 || delta == 0 { + return + } + idx := m.modal.Selected + for range m.modal.Fields { + idx += delta + if idx < 0 { + idx = len(m.modal.Fields) - 1 + } + if idx >= len(m.modal.Fields) { + idx = 0 + } + if !m.modal.Fields[idx].Disabled { + m.modal.Selected = idx + return + } + } +} + +func (m *tuiSecretRefModal) cycleField(delta int) { + field := &m.modal.Fields[m.modal.Selected] + if field.Disabled || field.Kind != tui.ModalFieldSelect || len(field.Options) == 0 { + return + } + idx := slices.Index(field.Options, field.Value) + if idx < 0 { + idx = 0 + } + idx += delta + if idx < 0 { + idx = len(field.Options) - 1 + } + if idx >= len(field.Options) { + idx = 0 + } + field.Value = field.Options[idx] + m.clearError() + m.updateFields() +} + +func (m *tuiSecretRefModal) appendField(text string) { + field := &m.modal.Fields[m.modal.Selected] + if field.Disabled || field.Kind != tui.ModalFieldText { + return + } + field.Value += text + m.clearError() +} + +func (m *tuiSecretRefModal) backspaceField() { + field := &m.modal.Fields[m.modal.Selected] + if field.Disabled || field.Kind != tui.ModalFieldText || field.Value == "" { + return + } + runes := []rune(field.Value) + field.Value = string(runes[:len(runes)-1]) + m.clearError() +} + +func (m *tuiSecretRefModal) updateFields() { + refField := m.fieldByKey("ref") + valueField := m.fieldByKey("value") + if refField == nil || valueField == nil { + return + } + if m.currentStorage() == "env" { + refField.Label = "Env Var" + refField.Required = true + if strings.Contains(refField.Value, "://") || strings.TrimSpace(refField.Value) == "" { + refField.Value = m.spec.DefaultEnvName + } + valueField.Disabled = true + valueField.Required = false + valueField.Value = "" + return + } + refField.Label = "Reference" + refField.Required = true + if parsed, err := secretref.Parse(refField.Value); err != nil || parsed.Scheme != m.currentStorage() { + if backend := m.backendByRef[m.currentStorage()]; backend != nil { + refField.Value = backend.DefaultRef(m.storeName, m.spec.DefaultAccount) + } + } + valueField.Disabled = false + valueField.Required = true +} + +func (m *tuiSecretRefModal) submit(ctx context.Context) (string, error) { + rawRef := strings.TrimSpace(m.fieldValue("ref")) + if m.currentStorage() == "env" { + if rawRef == "" { + return "", fieldError("ref", "environment variable name is required") + } + ref := envRef(rawRef) + if _, err := secretref.Parse(ref); err != nil { + return "", fieldError("ref", err.Error()) + } + return ref, nil + } + if rawRef == "" { + return "", fieldError("ref", "reference is required") + } + parsed, err := secretref.Parse(rawRef) + if err != nil { + return "", fieldError("ref", err.Error()) + } + if parsed.Scheme != m.currentStorage() { + return "", fieldError("ref", fmt.Sprintf("reference must use %s://", m.currentStorage())) + } + secretValue := m.fieldValue("value") + if secretValue == "" { + if rawRef == m.existingRef { + return rawRef, nil + } + return "", fieldError("value", "secret value is required") + } + if err := m.resolver.Store(ctx, rawRef, secretValue); err != nil { + return "", err + } + return rawRef, nil +} + +func (m *tuiSecretRefModal) currentStorage() string { + return firstNonEmpty(m.fieldValue("storage"), "env") +} + +func (m *tuiSecretRefModal) fieldByKey(key string) *tui.ModalField { + for i := range m.modal.Fields { + if m.modal.Fields[i].Key == key { + return &m.modal.Fields[i] + } + } + return nil +} + +func (m *tuiSecretRefModal) fieldValue(key string) string { + field := m.fieldByKey(key) + if field == nil { + return "" + } + return field.Value +} + +func (m *tuiSecretRefModal) clearError() { + m.modal.Error = "" + m.modal.ErrorField = "" +} + +func secretRefHelp(storage string, spec tuiSecretFieldSpec) []string { + if storage == "env" { + return []string{fmt.Sprintf("%sSave only a reference like env://%s. The secret value stays outside profiles.yaml.%s", ui.Dim, spec.DefaultEnvName, ui.Reset)} + } + return []string{fmt.Sprintf("%sThe secret will be stored now and the resulting %s:// reference will be saved in profiles.yaml.%s", ui.Dim, storage, ui.Reset)} +} diff --git a/cmd/cloudstic/cmd_tui_test.go b/cmd/cloudstic/cmd_tui_test.go index 0e99b39..c677a15 100644 --- a/cmd/cloudstic/cmd_tui_test.go +++ b/cmd/cloudstic/cmd_tui_test.go @@ -13,10 +13,35 @@ import ( "time" cloudstic "github.com/cloudstic/cli" + "github.com/cloudstic/cli/internal/secretref" "github.com/cloudstic/cli/internal/tui" xterm "golang.org/x/term" ) +type testWritableSecretBackend struct { + scheme string + displayName string + defaultRef string + storedRef string + storedValue string +} + +func (b *testWritableSecretBackend) Resolve(context.Context, secretref.Ref) (string, error) { + return "", nil +} +func (b *testWritableSecretBackend) Scheme() string { return b.scheme } +func (b *testWritableSecretBackend) DisplayName() string { return b.displayName } +func (b *testWritableSecretBackend) WriteSupported() bool { return true } +func (b *testWritableSecretBackend) DefaultRef(string, string) string { return b.defaultRef } +func (b *testWritableSecretBackend) Exists(context.Context, secretref.Ref) (bool, error) { + return false, nil +} +func (b *testWritableSecretBackend) Store(_ context.Context, ref secretref.Ref, value string) error { + b.storedRef = ref.Raw + b.storedValue = value + return nil +} + func TestTUIProfileSourceCompose(t *testing.T) { tests := []struct { name string @@ -115,6 +140,177 @@ func TestNewTUIProfileModal_AllowsCreatingStoreWhenNoneExist(t *testing.T) { } } +func TestNewTUIStoreModal_PopulatesExistingSecretFields(t *testing.T) { + dir := t.TempDir() + profilesPath := dir + "/profiles.yaml" + if err := cloudstic.SaveProfilesFile(profilesPath, &cloudstic.ProfilesConfig{ + Version: 1, + Stores: map[string]cloudstic.ProfileStore{ + "remote": { + URI: "s3:bucket/prod", + S3Region: "us-east-1", + S3Profile: "work", + S3Endpoint: "https://s3.example.com", + S3AccessKeySecret: "env://S3_ACCESS_KEY", + S3SecretKeySecret: "keychain://cloudstic/store/remote/s3-secret", + PasswordSecret: "keychain://cloudstic/store/remote/password", + KMSKeyARN: "arn:aws:kms:us-east-1:123:key/abc", + KMSRegion: "us-east-1", + KMSEndpoint: "https://kms.example.com", + }, + }, + }); err != nil { + t.Fatalf("SaveProfilesFile: %v", err) + } + + modal, err := newTUIStoreModal(profilesPath, "remote", true) + if err != nil { + t.Fatalf("newTUIStoreModal: %v", err) + } + if got := modal.fieldValue("store_type"); got != "s3" { + t.Fatalf("store_type=%q want s3", got) + } + if got := modal.fieldValue("s3_access_key_secret"); got != "env://S3_ACCESS_KEY" { + t.Fatalf("s3_access_key_secret=%q", got) + } + if got := modal.fieldValue("s3_secret_key_secret"); got != "keychain://cloudstic/store/remote/s3-secret" { + t.Fatalf("s3_secret_key_secret=%q", got) + } + if got := modal.fieldValue("encryption_mode"); got != string(tuiStoreEncryptionKMS) { + t.Fatalf("encryption_mode=%q want kms", got) + } + if got := modal.fieldValue("kms_key_arn"); got != "arn:aws:kms:us-east-1:123:key/abc" { + t.Fatalf("kms_key_arn=%q", got) + } + if field := modal.fieldByKey("kms_key_arn"); field == nil || field.Disabled { + t.Fatalf("expected kms_key_arn field to be enabled") + } +} + +func TestTUIStoreModalSubmit_SavesSecretRefs(t *testing.T) { + dir := t.TempDir() + profilesPath := dir + "/profiles.yaml" + if err := cloudstic.SaveProfilesFile(profilesPath, &cloudstic.ProfilesConfig{ + Version: 1, + }); err != nil { + t.Fatalf("SaveProfilesFile: %v", err) + } + + modal, err := newTUIStoreModal(profilesPath, "", false) + if err != nil { + t.Fatalf("newTUIStoreModal: %v", err) + } + modal.fieldByKey("name").Value = "remote" + modal.fieldByKey("store_type").Value = "s3" + modal.fieldByKey("store_value").Value = "bucket/prod" + modal.fieldByKey("s3_region").Value = "us-east-1" + modal.fieldByKey("s3_endpoint").Value = "https://s3.example.com" + modal.fieldByKey("s3_access_key_secret").Value = "env://S3_ACCESS_KEY" + modal.fieldByKey("s3_secret_key_secret").Value = "keychain://cloudstic/store/remote/s3-secret" + modal.fieldByKey("encryption_mode").Value = string(tuiStoreEncryptionPassword) + modal.rebuildDerivedFields() + modal.fieldByKey("password_secret").Value = "keychain://cloudstic/store/remote/password" + + name, err := modal.submit() + if err != nil { + t.Fatalf("submit: %v", err) + } + if name != "remote" { + t.Fatalf("name=%q want remote", name) + } + + cfg, err := cloudstic.LoadProfilesFile(profilesPath) + if err != nil { + t.Fatalf("LoadProfilesFile: %v", err) + } + store := cfg.Stores["remote"] + if store.URI != "s3:bucket/prod" { + t.Fatalf("uri=%q want s3:bucket/prod", store.URI) + } + if store.S3Region != "us-east-1" || store.S3Endpoint != "https://s3.example.com" { + t.Fatalf("unexpected s3 config: %+v", store) + } + if store.S3AccessKeySecret != "env://S3_ACCESS_KEY" || store.S3SecretKeySecret != "keychain://cloudstic/store/remote/s3-secret" { + t.Fatalf("unexpected s3 secret refs: %+v", store) + } + if store.PasswordSecret != "keychain://cloudstic/store/remote/password" { + t.Fatalf("password secret=%q", store.PasswordSecret) + } + if store.RecoveryKeySecret != "" { + t.Fatalf("expected no recovery secret in store form, got %q", store.RecoveryKeySecret) + } + if store.KMSKeyARN != "" { + t.Fatalf("expected kms config to be cleared: %+v", store) + } +} + +func TestTUIStoreModalView_HidesIrrelevantFields(t *testing.T) { + dir := t.TempDir() + profilesPath := dir + "/profiles.yaml" + if err := cloudstic.SaveProfilesFile(profilesPath, &cloudstic.ProfilesConfig{ + Version: 1, + Stores: map[string]cloudstic.ProfileStore{ + "local-store": { + URI: "local:/tmp/backups", + S3Region: "us-east-1", + S3Endpoint: "https://s3.example.com", + S3AccessKeySecret: "env://S3_ACCESS_KEY", + KMSKeyARN: "", + }, + }, + }); err != nil { + t.Fatalf("SaveProfilesFile: %v", err) + } + + modal, err := newTUIStoreModal(profilesPath, "local-store", true) + if err != nil { + t.Fatalf("newTUIStoreModal: %v", err) + } + view := modal.View() + for _, key := range []string{"s3_region", "s3_endpoint", "s3_access_key_secret", "password_secret", "kms_key_arn"} { + for _, field := range view.Fields { + if field.Key == key { + t.Fatalf("did not expect field %q in visible view", key) + } + } + } +} + +func TestTUISecretRefModal_SubmitStoresSecretInWritableBackend(t *testing.T) { + backend := &testWritableSecretBackend{ + scheme: "test", + displayName: "Test Backend", + defaultRef: "test://cloudstic/store/remote/password", + } + oldResolver := tuiSecretResolver + t.Cleanup(func() { tuiSecretResolver = oldResolver }) + tuiSecretResolver = secretref.NewResolver(map[string]secretref.Backend{ + "env": secretref.NewEnvBackend(nil), + "test": backend, + }) + + modal := newTUISecretRefModal("remote", tuiSecretFieldSpec{ + FieldKey: "password_secret", + SecretLabel: "repository password", + DefaultEnvName: "CLOUDSTIC_PASSWORD", + DefaultAccount: "password", + }, "") + modal.fieldByKey("storage").Value = "test" + modal.updateFields() + modal.fieldByKey("value").Value = "super-secret" + + ref, err := modal.submit(context.Background()) + if err != nil { + t.Fatalf("submit: %v", err) + } + if ref != "test://cloudstic/store/remote/password" { + t.Fatalf("ref=%q", ref) + } + if backend.storedRef != ref || backend.storedValue != "super-secret" { + t.Fatalf("unexpected stored secret: ref=%q value=%q", backend.storedRef, backend.storedValue) + } +} + func stubTUITestHooks(t *testing.T) { t.Helper()