From a9da841a3f6554bf5148cb5840c9e5bd3c607695 Mon Sep 17 00:00:00 2001
From: Jahvon Dockery
Date: Sat, 9 Aug 2025 13:53:48 -0400
Subject: [PATCH 1/6] feat: add unencrypted vault provider
---
.claude/settings.local.json | 10 +
config.go | 36 +++-
unencrypted.go | 221 ++++++++++++++++++++++
unencrypted_test.go | 353 ++++++++++++++++++++++++++++++++++++
vault.go | 17 +-
5 files changed, 628 insertions(+), 9 deletions(-)
create mode 100644 .claude/settings.local.json
create mode 100644 unencrypted.go
create mode 100644 unencrypted_test.go
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..e19b2e3
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,10 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(go test:*)",
+ "Bash(go build:*)",
+ "mcp__flow__execute"
+ ],
+ "deny": []
+ }
+}
\ No newline at end of file
diff --git a/config.go b/config.go
index dc1214b..3a013e9 100644
--- a/config.go
+++ b/config.go
@@ -11,17 +11,19 @@ import (
type ProviderType string
const (
- ProviderTypeAES256 ProviderType = "aes256"
- ProviderTypeAge ProviderType = "age"
- ProviderTypeExternal ProviderType = "external"
+ ProviderTypeAES256 ProviderType = "aes256"
+ ProviderTypeAge ProviderType = "age"
+ ProviderTypeExternal ProviderType = "external"
+ ProviderTypeUnencrypted ProviderType = "unencrypted"
)
type Config struct {
- ID string `json:"id"`
- Type ProviderType `json:"type"`
- Age *AgeConfig `json:"age,omitempty"`
- Aes *AesConfig `json:"aes,omitempty"`
- External *ExternalConfig `json:"external,omitempty"`
+ ID string `json:"id"`
+ Type ProviderType `json:"type"`
+ Age *AgeConfig `json:"age,omitempty"`
+ Aes *AesConfig `json:"aes,omitempty"`
+ External *ExternalConfig `json:"external,omitempty"`
+ Unencrypted *UnencryptedConfig `json:"unencrypted,omitempty"`
}
func (c *Config) Validate() error {
@@ -45,6 +47,11 @@ func (c *Config) Validate() error {
return fmt.Errorf("%w: external configuration required for external vault", ErrInvalidConfig)
}
return c.External.Validate()
+ case ProviderTypeUnencrypted:
+ if c.Unencrypted == nil {
+ return fmt.Errorf("%w: unencrypted configuration required for unencrypted vault provider", ErrInvalidConfig)
+ }
+ return c.Unencrypted.Validate()
default:
return fmt.Errorf("%w: unsupported vault type: %s", ErrInvalidConfig, c.Type)
}
@@ -197,3 +204,16 @@ func (c *ExternalConfig) Validate() error {
}
return nil
}
+
+// UnencryptedConfig contains unencrypted (plain text) vault configuration
+type UnencryptedConfig struct {
+ // Storage location for the vault file
+ StoragePath string `json:"storage_path"`
+}
+
+func (c *UnencryptedConfig) Validate() error {
+ if c.StoragePath == "" {
+ return fmt.Errorf("%w: storage path is required for unencrypted vault", ErrInvalidConfig)
+ }
+ return nil
+}
diff --git a/unencrypted.go b/unencrypted.go
new file mode 100644
index 0000000..681b741
--- /dev/null
+++ b/unencrypted.go
@@ -0,0 +1,221 @@
+package vault
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "sync"
+ "time"
+)
+
+const (
+ unencryptedCurrentVaultVersion = 1
+ unencryptedVaultFileExt = "json"
+)
+
+// UnencryptedState represents the state of the unencrypted vault.
+type UnencryptedState struct {
+ Metadata `json:"metadata"`
+
+ Version int `json:"version"`
+ ID string `json:"id"`
+ Secrets map[string]string `json:"secrets"`
+}
+
+// UnencryptedVault manages operations on an instance of an unencrypted vault that stores secrets in JSON format.
+type UnencryptedVault struct {
+ mu sync.RWMutex
+ id string
+ fullPath string
+
+ state *UnencryptedState
+}
+
+func NewUnencryptedVault(cfg *Config) (*UnencryptedVault, error) {
+ if cfg.Unencrypted == nil {
+ return nil, fmt.Errorf("unencrypted configuration is required")
+ }
+
+ path := filepath.Join(
+ filepath.Clean(cfg.Unencrypted.StoragePath),
+ filepath.Clean(fmt.Sprintf("%s-%s.%s", vaultFileBase, cfg.ID, unencryptedVaultFileExt)),
+ )
+
+ vault := &UnencryptedVault{
+ id: cfg.ID,
+ fullPath: path,
+ }
+
+ if err := vault.load(); err != nil {
+ return nil, fmt.Errorf("failed to load vault: %w", err)
+ }
+
+ if vault.state == nil {
+ if err := vault.init(); err != nil {
+ return nil, fmt.Errorf("failed to initialize vault: %w", err)
+ }
+ }
+
+ return vault, nil
+}
+
+func (v *UnencryptedVault) init() error {
+ now := time.Now()
+ v.state = &UnencryptedState{
+ Version: unencryptedCurrentVaultVersion,
+ ID: v.id,
+ Metadata: Metadata{
+ Created: now,
+ LastModified: now,
+ },
+ Secrets: make(map[string]string),
+ }
+
+ return v.save()
+}
+
+// load retrieves the vault contents from the file and parses it into the state.
+func (v *UnencryptedVault) load() error {
+ data, err := os.ReadFile(filepath.Clean(v.fullPath))
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ return fmt.Errorf("%w: failed to read vault file %s: %w", ErrVaultNotFound, v.fullPath, err)
+ }
+
+ if len(data) == 0 {
+ return nil
+ }
+
+ // Parse the JSON format
+ var state UnencryptedState
+ if err := json.Unmarshal(data, &state); err != nil {
+ return fmt.Errorf("failed to parse vault file: %w", err)
+ }
+
+ v.state = &state
+ return nil
+}
+
+// save writes the vault contents to disk in JSON format
+func (v *UnencryptedVault) save() error {
+ if v.state == nil {
+ return nil
+ }
+
+ v.state.LastModified = time.Now()
+
+ // Marshal to JSON with indentation for readability
+ data, err := json.MarshalIndent(v.state, "", " ")
+ if err != nil {
+ return fmt.Errorf("failed to marshal vault state: %w", err)
+ }
+
+ // Write to file atomically
+ if err := os.MkdirAll(filepath.Dir(v.fullPath), 0750); err != nil {
+ return fmt.Errorf("failed to create vault directory: %w", err)
+ }
+
+ tempFile := v.fullPath + ".tmp"
+ if err := os.WriteFile(tempFile, data, 0600); err != nil {
+ return fmt.Errorf("failed to write temp vault file: %w", err)
+ }
+
+ if err := os.Rename(tempFile, v.fullPath); err != nil {
+ _ = os.Remove(tempFile)
+ return fmt.Errorf("failed to move vault file: %w", err)
+ }
+
+ return nil
+}
+
+func (v *UnencryptedVault) ID() string {
+ return v.id
+}
+
+func (v *UnencryptedVault) Metadata() Metadata {
+ v.mu.RLock()
+ defer v.mu.RUnlock()
+
+ if v.state == nil {
+ return Metadata{}
+ }
+ return v.state.Metadata
+}
+
+func (v *UnencryptedVault) GetSecret(key string) (Secret, error) {
+ v.mu.RLock()
+ defer v.mu.RUnlock()
+
+ value, exists := v.state.Secrets[key]
+ if !exists {
+ return nil, ErrSecretNotFound
+ }
+
+ return NewSecretValue([]byte(value)), nil
+}
+
+func (v *UnencryptedVault) SetSecret(key string, secret Secret) error {
+ v.mu.Lock()
+ defer v.mu.Unlock()
+
+ if err := ValidateSecretKey(key); err != nil {
+ return err
+ }
+
+ if v.state.Secrets == nil {
+ v.state.Secrets = make(map[string]string)
+ }
+
+ v.state.Secrets[key] = secret.PlainTextString()
+ return v.save()
+}
+
+func (v *UnencryptedVault) DeleteSecret(key string) error {
+ v.mu.Lock()
+ defer v.mu.Unlock()
+
+ _, exists := v.state.Secrets[key]
+ if !exists {
+ return ErrSecretNotFound
+ }
+
+ delete(v.state.Secrets, key)
+ return v.save()
+}
+
+func (v *UnencryptedVault) ListSecrets() ([]string, error) {
+ v.mu.RLock()
+ defer v.mu.RUnlock()
+
+ keys := make([]string, 0, len(v.state.Secrets))
+ for k := range v.state.Secrets {
+ keys = append(keys, k)
+ }
+
+ // Sort for deterministic output
+ sort.Strings(keys)
+ return keys, nil
+}
+
+func (v *UnencryptedVault) HasSecret(key string) (bool, error) {
+ v.mu.RLock()
+ defer v.mu.RUnlock()
+
+ _, exists := v.state.Secrets[key]
+ return exists, nil
+}
+
+func (v *UnencryptedVault) Close() error {
+ // clear the secret state from memory
+ v.mu.Lock()
+ defer v.mu.Unlock()
+
+ v.state = nil
+
+ return nil
+}
diff --git a/unencrypted_test.go b/unencrypted_test.go
new file mode 100644
index 0000000..a7c88c6
--- /dev/null
+++ b/unencrypted_test.go
@@ -0,0 +1,353 @@
+package vault_test
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/flowexec/vault"
+)
+
+func TestUnencryptedVault_New(t *testing.T) {
+ tempDir := t.TempDir()
+
+ vlt, cfg, err := vault.New("test-vault",
+ vault.WithProvider(vault.ProviderTypeUnencrypted),
+ vault.WithUnencryptedPath(tempDir),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create unencrypted vault: %v", err)
+ }
+ defer vlt.Close()
+
+ if vlt.ID() != "test-vault" {
+ t.Errorf("Expected vault ID 'test-vault', got '%s'", vlt.ID())
+ }
+
+ if cfg.Type != vault.ProviderTypeUnencrypted {
+ t.Errorf("Expected provider type '%s', got '%s'", vault.ProviderTypeUnencrypted, cfg.Type)
+ }
+
+ if cfg.Unencrypted == nil {
+ t.Fatal("Expected unencrypted config to be set")
+ }
+
+ if cfg.Unencrypted.StoragePath != tempDir {
+ t.Errorf("Expected storage path '%s', got '%s'", tempDir, cfg.Unencrypted.StoragePath)
+ }
+}
+
+func TestUnencryptedVault_SecretOperations(t *testing.T) {
+ tempDir := t.TempDir()
+
+ vlt, _, err := vault.New("test-vault",
+ vault.WithProvider(vault.ProviderTypeUnencrypted),
+ vault.WithUnencryptedPath(tempDir),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create unencrypted vault: %v", err)
+ }
+ defer vlt.Close()
+
+ // Test setting a secret
+ testKey := "test-key"
+ testValue := "test-value"
+ secret := vault.NewSecretValue([]byte(testValue))
+
+ err = vlt.SetSecret(testKey, secret)
+ if err != nil {
+ t.Fatalf("Failed to set secret: %v", err)
+ }
+
+ // Test getting the secret
+ retrievedSecret, err := vlt.GetSecret(testKey)
+ if err != nil {
+ t.Fatalf("Failed to get secret: %v", err)
+ }
+
+ if retrievedSecret.PlainTextString() != testValue {
+ t.Errorf("Expected secret value '%s', got '%s'", testValue, retrievedSecret.PlainTextString())
+ }
+
+ // Test HasSecret
+ exists, err := vlt.HasSecret(testKey)
+ if err != nil {
+ t.Fatalf("Failed to check if secret exists: %v", err)
+ }
+ if !exists {
+ t.Error("Expected secret to exist")
+ }
+
+ // Test ListSecrets
+ secrets, err := vlt.ListSecrets()
+ if err != nil {
+ t.Fatalf("Failed to list secrets: %v", err)
+ }
+ if len(secrets) != 1 {
+ t.Errorf("Expected 1 secret, got %d", len(secrets))
+ }
+ if secrets[0] != testKey {
+ t.Errorf("Expected secret key '%s', got '%s'", testKey, secrets[0])
+ }
+
+ // Test deleting the secret
+ err = vlt.DeleteSecret(testKey)
+ if err != nil {
+ t.Fatalf("Failed to delete secret: %v", err)
+ }
+
+ // Verify secret is gone
+ exists, err = vlt.HasSecret(testKey)
+ if err != nil {
+ t.Fatalf("Failed to check if secret exists after deletion: %v", err)
+ }
+ if exists {
+ t.Error("Expected secret to not exist after deletion")
+ }
+
+ // Test getting non-existent secret
+ _, err = vlt.GetSecret(testKey)
+ if !errors.Is(err, vault.ErrSecretNotFound) {
+ t.Errorf("Expected ErrSecretNotFound, got %v", err)
+ }
+
+ // Test deleting non-existent secret
+ err = vlt.DeleteSecret(testKey)
+ if !errors.Is(err, vault.ErrSecretNotFound) {
+ t.Errorf("Expected ErrSecretNotFound when deleting non-existent secret, got %v", err)
+ }
+}
+
+func TestUnencryptedVault_FileFormat(t *testing.T) {
+ tempDir := t.TempDir()
+
+ vlt, _, err := vault.New("test-vault",
+ vault.WithProvider(vault.ProviderTypeUnencrypted),
+ vault.WithUnencryptedPath(tempDir),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create unencrypted vault: %v", err)
+ }
+ defer vlt.Close()
+
+ // Set multiple secrets
+ secrets := map[string]string{
+ "API_KEY": "secret-api-key",
+ "DATABASE_URL": "postgresql://user:pass@host:5432/db",
+ "DEBUG": "true",
+ "QUOTED_VALUE": "value with spaces",
+ }
+
+ for key, value := range secrets {
+ err = vlt.SetSecret(key, vault.NewSecretValue([]byte(value)))
+ if err != nil {
+ t.Fatalf("Failed to set secret %s: %v", key, err)
+ }
+ }
+
+ // Read the file directly to verify JSON format
+ vaultFilePath := filepath.Join(tempDir, "vault-test-vault.json")
+ content, err := os.ReadFile(vaultFilePath)
+ if err != nil {
+ t.Fatalf("Failed to read vault file: %v", err)
+ }
+
+ contentStr := string(content)
+ t.Logf("Vault file content:\n%s", contentStr)
+
+ // Parse the JSON to verify structure
+ var vaultData map[string]interface{}
+ if err := json.Unmarshal(content, &vaultData); err != nil {
+ t.Fatalf("Expected valid JSON format, got parse error: %v", err)
+ }
+
+ // Verify required fields exist
+ if vaultData["id"] != "test-vault" {
+ t.Errorf("Expected vault ID 'test-vault', got %v", vaultData["id"])
+ }
+ if vaultData["version"] != float64(1) {
+ t.Errorf("Expected version 1, got %v", vaultData["version"])
+ }
+
+ // Verify metadata exists
+ metadata, metadataExists := vaultData["metadata"].(map[string]interface{})
+ if !metadataExists {
+ t.Fatal("Expected metadata field in vault file")
+ }
+ if _, createdExists := metadata["created"]; !createdExists {
+ t.Error("Expected created timestamp in metadata")
+ }
+ if _, lastModifiedExists := metadata["lastModified"]; !lastModifiedExists {
+ t.Error("Expected lastModified timestamp in metadata")
+ }
+
+ // Verify secrets are properly stored
+ secretsData, secretsExists := vaultData["secrets"].(map[string]interface{})
+ if !secretsExists {
+ t.Fatal("Expected secrets field in vault file")
+ }
+
+ for key, expectedValue := range secrets {
+ actualValue, valueExists := secretsData[key]
+ if !valueExists {
+ t.Errorf("Expected secret key '%s' to be present", key)
+ continue
+ }
+ if actualValue != expectedValue {
+ t.Errorf("Expected secret value '%s' for key '%s', got '%v'", expectedValue, key, actualValue)
+ }
+ }
+}
+
+func TestUnencryptedVault_Persistence(t *testing.T) {
+ tempDir := t.TempDir()
+
+ // Create first vault instance and add a secret
+ vault1, _, err := vault.New("test-vault",
+ vault.WithProvider(vault.ProviderTypeUnencrypted),
+ vault.WithUnencryptedPath(tempDir),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create first vault: %v", err)
+ }
+
+ testKey := "persistent-key"
+ testValue := "persistent-value"
+ err = vault1.SetSecret(testKey, vault.NewSecretValue([]byte(testValue)))
+ if err != nil {
+ t.Fatalf("Failed to set secret in first vault: %v", err)
+ }
+ vault1.Close()
+
+ // Create second vault instance with same configuration
+ vault2, _, err := vault.New("test-vault",
+ vault.WithProvider(vault.ProviderTypeUnencrypted),
+ vault.WithUnencryptedPath(tempDir),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create second vault: %v", err)
+ }
+ defer vault2.Close()
+
+ // Verify secret persists
+ retrievedSecret, err := vault2.GetSecret(testKey)
+ if err != nil {
+ t.Fatalf("Failed to get secret from second vault: %v", err)
+ }
+
+ if retrievedSecret.PlainTextString() != testValue {
+ t.Errorf("Expected persisted secret value '%s', got '%s'", testValue, retrievedSecret.PlainTextString())
+ }
+
+ // Verify metadata is preserved
+ metadata := vault2.Metadata()
+ if metadata.Created.IsZero() {
+ t.Error("Expected creation time to be preserved")
+ }
+ if metadata.LastModified.IsZero() {
+ t.Error("Expected last modified time to be preserved")
+ }
+}
+
+func TestUnencryptedVault_Metadata(t *testing.T) {
+ tempDir := t.TempDir()
+
+ vlt, _, err := vault.New("test-vault",
+ vault.WithProvider(vault.ProviderTypeUnencrypted),
+ vault.WithUnencryptedPath(tempDir),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create vault: %v", err)
+ }
+ defer vlt.Close()
+
+ metadata := vlt.Metadata()
+ if metadata.Created.IsZero() {
+ t.Error("Expected creation time to be set")
+ }
+ if metadata.LastModified.IsZero() {
+ t.Error("Expected last modified time to be set")
+ }
+
+ // Add a secret and verify last modified time is updated
+ oldModified := metadata.LastModified
+ time.Sleep(10 * time.Millisecond) // Ensure some time passes
+
+ err = vlt.SetSecret("test-key", vault.NewSecretValue([]byte("test-value")))
+ if err != nil {
+ t.Fatalf("Failed to set secret: %v", err)
+ }
+
+ newMetadata := vlt.Metadata()
+ if !newMetadata.LastModified.After(oldModified) {
+ t.Error("Expected last modified time to be updated after setting secret")
+ }
+}
+
+func TestUnencryptedVault_SortedOutput(t *testing.T) {
+ tempDir := t.TempDir()
+
+ vlt, _, err := vault.New("test-vault",
+ vault.WithProvider(vault.ProviderTypeUnencrypted),
+ vault.WithUnencryptedPath(tempDir),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create vault: %v", err)
+ }
+ defer vlt.Close()
+
+ // Add secrets in non-alphabetical order
+ secretKeys := []string{"zebra", "alpha", "beta", "gamma"}
+ for _, key := range secretKeys {
+ err = vlt.SetSecret(key, vault.NewSecretValue([]byte("value-"+key)))
+ if err != nil {
+ t.Fatalf("Failed to set secret %s: %v", key, err)
+ }
+ }
+
+ // List secrets and verify they are sorted
+ listedKeys, err := vlt.ListSecrets()
+ if err != nil {
+ t.Fatalf("Failed to list secrets: %v", err)
+ }
+
+ expectedOrder := []string{"alpha", "beta", "gamma", "zebra"}
+ if len(listedKeys) != len(expectedOrder) {
+ t.Fatalf("Expected %d keys, got %d", len(expectedOrder), len(listedKeys))
+ }
+
+ for i, expected := range expectedOrder {
+ if listedKeys[i] != expected {
+ t.Errorf("Expected key at position %d to be '%s', got '%s'", i, expected, listedKeys[i])
+ }
+ }
+
+ // Verify JSON format maintains sorted order (JSON doesn't guarantee key order,
+ // but our implementation should maintain sorted keys in memory)
+ vaultFilePath := filepath.Join(tempDir, "vault-test-vault.json")
+ content, err := os.ReadFile(vaultFilePath)
+ if err != nil {
+ t.Fatalf("Failed to read vault file: %v", err)
+ }
+
+ // Parse JSON to verify secrets are present
+ var vaultData map[string]interface{}
+ if err := json.Unmarshal(content, &vaultData); err != nil {
+ t.Fatalf("Expected valid JSON format, got parse error: %v", err)
+ }
+
+ secretsData, secretsExists := vaultData["secrets"].(map[string]interface{})
+ if !secretsExists {
+ t.Fatal("Expected secrets field in JSON")
+ }
+
+ // Verify all expected keys are present in the JSON
+ for _, expectedKey := range expectedOrder {
+ if _, keyExists := secretsData[expectedKey]; !keyExists {
+ t.Errorf("Expected secret key '%s' to be present in JSON", expectedKey)
+ }
+ }
+}
diff --git a/vault.go b/vault.go
index 5094aaa..ea66f17 100644
--- a/vault.go
+++ b/vault.go
@@ -39,6 +39,9 @@ func New(id string, opts ...Option) (Provider, *Config, error) {
case ProviderTypeAES256:
provider, err := NewAES256Vault(config)
return provider, config, err
+ case ProviderTypeUnencrypted:
+ provider, err := NewUnencryptedVault(config)
+ return provider, config, err
case ProviderTypeExternal:
return nil, nil, fmt.Errorf("external vault provider not implemented yet")
}
@@ -72,7 +75,17 @@ func WithAESPath(path string) Option {
}
}
-// WithLocalPath sets the local vault storage path (works for both Age and AES based on provider type)
+// WithUnencryptedPath sets the unencrypted vault storage path
+func WithUnencryptedPath(path string) Option {
+ return func(c *Config) {
+ if c.Unencrypted == nil {
+ c.Unencrypted = &UnencryptedConfig{}
+ }
+ c.Unencrypted.StoragePath = path
+ }
+}
+
+// WithLocalPath sets the local vault storage path (works for Age, AES, and Unencrypted based on provider type)
func WithLocalPath(path string) Option {
return func(c *Config) {
//nolint:exhaustive
@@ -81,6 +94,8 @@ func WithLocalPath(path string) Option {
WithAgePath(path)(c)
case ProviderTypeAES256:
WithAESPath(path)(c)
+ case ProviderTypeUnencrypted:
+ WithUnencryptedPath(path)(c)
}
}
}
From 1737784900c2ec754482faa082496e7d1cb9c34e Mon Sep 17 00:00:00 2001
From: Jahvon Dockery
Date: Sat, 9 Aug 2025 14:08:48 -0400
Subject: [PATCH 2/6] feat: add keyring vault provider
---
.claude/settings.local.json | 3 +-
config.go | 20 ++
go.mod | 8 +-
go.sum | 18 ++
keyring.go | 274 ++++++++++++++++++++++++++
keyring_test.go | 369 ++++++++++++++++++++++++++++++++++++
vault.go | 13 ++
7 files changed, 703 insertions(+), 2 deletions(-)
create mode 100644 keyring.go
create mode 100644 keyring_test.go
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index e19b2e3..cf6b2b1 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -3,7 +3,8 @@
"allow": [
"Bash(go test:*)",
"Bash(go build:*)",
- "mcp__flow__execute"
+ "mcp__flow__execute",
+ "mcp__flow__list_executables"
],
"deny": []
}
diff --git a/config.go b/config.go
index 3a013e9..e591b4f 100644
--- a/config.go
+++ b/config.go
@@ -14,6 +14,7 @@ const (
ProviderTypeAES256 ProviderType = "aes256"
ProviderTypeAge ProviderType = "age"
ProviderTypeExternal ProviderType = "external"
+ ProviderTypeKeyring ProviderType = "keyring"
ProviderTypeUnencrypted ProviderType = "unencrypted"
)
@@ -23,6 +24,7 @@ type Config struct {
Age *AgeConfig `json:"age,omitempty"`
Aes *AesConfig `json:"aes,omitempty"`
External *ExternalConfig `json:"external,omitempty"`
+ Keyring *KeyringConfig `json:"keyring,omitempty"`
Unencrypted *UnencryptedConfig `json:"unencrypted,omitempty"`
}
@@ -47,6 +49,11 @@ func (c *Config) Validate() error {
return fmt.Errorf("%w: external configuration required for external vault", ErrInvalidConfig)
}
return c.External.Validate()
+ case ProviderTypeKeyring:
+ if c.Keyring == nil {
+ return fmt.Errorf("%w: keyring configuration required for keyring vault provider", ErrInvalidConfig)
+ }
+ return c.Keyring.Validate()
case ProviderTypeUnencrypted:
if c.Unencrypted == nil {
return fmt.Errorf("%w: unencrypted configuration required for unencrypted vault provider", ErrInvalidConfig)
@@ -217,3 +224,16 @@ func (c *UnencryptedConfig) Validate() error {
}
return nil
}
+
+// KeyringConfig contains keyring vault configuration
+type KeyringConfig struct {
+ // Service name used for keyring operations
+ Service string `json:"service"`
+}
+
+func (c *KeyringConfig) Validate() error {
+ if c.Service == "" {
+ return fmt.Errorf("%w: service name is required for keyring vault", ErrInvalidConfig)
+ }
+ return nil
+}
diff --git a/go.mod b/go.mod
index 7ab804e..747e28b 100644
--- a/go.mod
+++ b/go.mod
@@ -7,9 +7,15 @@ require (
golang.org/x/crypto v0.39.0
)
-require gopkg.in/yaml.v3 v3.0.1
+require (
+ github.com/zalando/go-keyring v0.2.6
+ gopkg.in/yaml.v3 v3.0.1
+)
require (
+ al.essio.dev/pkg/shellescape v1.5.1 // indirect
+ github.com/danieljoos/wincred v1.2.2 // indirect
+ github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
golang.org/x/sys v0.33.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
diff --git a/go.sum b/go.sum
index c6fe5ce..779edbb 100644
--- a/go.sum
+++ b/go.sum
@@ -1,8 +1,18 @@
+al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho=
+al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0=
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w=
filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o=
filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
+github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -11,9 +21,17 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
+github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s=
+github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
diff --git a/keyring.go b/keyring.go
new file mode 100644
index 0000000..47ed228
--- /dev/null
+++ b/keyring.go
@@ -0,0 +1,274 @@
+package vault
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "sort"
+ "sync"
+ "time"
+
+ "github.com/zalando/go-keyring"
+)
+
+// KeyringVault manages operations on a keyring-based vault that stores secrets in the system keyring.
+type KeyringVault struct {
+ mu sync.RWMutex
+ id string
+ service string
+
+ metadata Metadata
+}
+
+func NewKeyringVault(cfg *Config) (*KeyringVault, error) {
+ if cfg.Keyring == nil {
+ return nil, fmt.Errorf("keyring configuration is required")
+ }
+
+ vault := &KeyringVault{
+ id: cfg.ID,
+ service: cfg.Keyring.Service,
+ }
+
+ // Try to load metadata or initialize if not exists
+ if err := vault.loadMetadata(); err != nil {
+ if err := vault.initMetadata(); err != nil {
+ return nil, fmt.Errorf("failed to initialize keyring vault metadata: %w", err)
+ }
+ }
+
+ return vault, nil
+}
+
+func (v *KeyringVault) metadataKey() string {
+ return fmt.Sprintf("%s-metadata", v.id)
+}
+
+func (v *KeyringVault) secretKey(key string) string {
+ return fmt.Sprintf("%s-secret-%s", v.id, key)
+}
+
+func (v *KeyringVault) secretsListKey() string {
+ return fmt.Sprintf("%s-secrets-list", v.id)
+}
+
+func (v *KeyringVault) initMetadata() error {
+ now := time.Now()
+ v.metadata = Metadata{
+ Created: now,
+ LastModified: now,
+ }
+
+ return v.saveMetadata()
+}
+
+func (v *KeyringVault) loadMetadata() error {
+ data, err := keyring.Get(v.service, v.metadataKey())
+ if err != nil {
+ return err
+ }
+
+ var metadata Metadata
+ if err := json.Unmarshal([]byte(data), &metadata); err != nil {
+ return fmt.Errorf("failed to unmarshal metadata: %w", err)
+ }
+
+ v.metadata = metadata
+ return nil
+}
+
+func (v *KeyringVault) saveMetadata() error {
+ v.metadata.LastModified = time.Now()
+
+ data, err := json.Marshal(v.metadata)
+ if err != nil {
+ return fmt.Errorf("failed to marshal metadata: %w", err)
+ }
+
+ return keyring.Set(v.service, v.metadataKey(), string(data))
+}
+
+func (v *KeyringVault) loadSecretsList() ([]string, error) {
+ data, err := keyring.Get(v.service, v.secretsListKey())
+ if err != nil {
+ if errors.Is(err, keyring.ErrNotFound) {
+ return []string{}, nil
+ }
+ return nil, err
+ }
+
+ var secrets []string
+ if err := json.Unmarshal([]byte(data), &secrets); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal secrets list: %w", err)
+ }
+
+ return secrets, nil
+}
+
+func (v *KeyringVault) saveSecretsList(secrets []string) error {
+ data, err := json.Marshal(secrets)
+ if err != nil {
+ return fmt.Errorf("failed to marshal secrets list: %w", err)
+ }
+
+ return keyring.Set(v.service, v.secretsListKey(), string(data))
+}
+
+func (v *KeyringVault) addSecretToList(key string) error {
+ secrets, err := v.loadSecretsList()
+ if err != nil {
+ return err
+ }
+
+ // Check if secret already exists in list
+ for _, s := range secrets {
+ if s == key {
+ return nil // Already exists
+ }
+ }
+
+ secrets = append(secrets, key)
+ sort.Strings(secrets)
+
+ return v.saveSecretsList(secrets)
+}
+
+func (v *KeyringVault) removeSecretFromList(key string) error {
+ secrets, err := v.loadSecretsList()
+ if err != nil {
+ return err
+ }
+
+ // Remove the secret from the list
+ for i, s := range secrets {
+ if s == key {
+ secrets = append(secrets[:i], secrets[i+1:]...)
+ break
+ }
+ }
+
+ return v.saveSecretsList(secrets)
+}
+
+func (v *KeyringVault) ID() string {
+ return v.id
+}
+
+func (v *KeyringVault) Metadata() Metadata {
+ v.mu.RLock()
+ defer v.mu.RUnlock()
+
+ return v.metadata
+}
+
+func (v *KeyringVault) GetSecret(key string) (Secret, error) {
+ v.mu.RLock()
+ defer v.mu.RUnlock()
+
+ if err := ValidateSecretKey(key); err != nil {
+ return nil, err
+ }
+
+ data, err := keyring.Get(v.service, v.secretKey(key))
+ if err != nil {
+ if errors.Is(err, keyring.ErrNotFound) {
+ return nil, ErrSecretNotFound
+ }
+ return nil, fmt.Errorf("failed to get secret from keyring: %w", err)
+ }
+
+ return NewSecretValue([]byte(data)), nil
+}
+
+func (v *KeyringVault) SetSecret(key string, secret Secret) error {
+ v.mu.Lock()
+ defer v.mu.Unlock()
+
+ if err := ValidateSecretKey(key); err != nil {
+ return err
+ }
+
+ if err := keyring.Set(v.service, v.secretKey(key), secret.PlainTextString()); err != nil {
+ return fmt.Errorf("failed to set secret in keyring: %w", err)
+ }
+
+ if err := v.addSecretToList(key); err != nil {
+ return fmt.Errorf("failed to update secrets list: %w", err)
+ }
+
+ return v.saveMetadata()
+}
+
+func (v *KeyringVault) DeleteSecret(key string) error {
+ v.mu.Lock()
+ defer v.mu.Unlock()
+
+ if err := ValidateSecretKey(key); err != nil {
+ return err
+ }
+
+ // Check if secret exists first
+ _, err := keyring.Get(v.service, v.secretKey(key))
+ if err != nil {
+ if errors.Is(err, keyring.ErrNotFound) {
+ return ErrSecretNotFound
+ }
+ return fmt.Errorf("failed to check secret existence: %w", err)
+ }
+
+ if err := keyring.Delete(v.service, v.secretKey(key)); err != nil {
+ return fmt.Errorf("failed to delete secret from keyring: %w", err)
+ }
+
+ if err := v.removeSecretFromList(key); err != nil {
+ return fmt.Errorf("failed to update secrets list: %w", err)
+ }
+
+ return v.saveMetadata()
+}
+
+func (v *KeyringVault) ListSecrets() ([]string, error) {
+ v.mu.RLock()
+ defer v.mu.RUnlock()
+
+ secrets, err := v.loadSecretsList()
+ if err != nil {
+ return nil, fmt.Errorf("failed to load secrets list: %w", err)
+ }
+
+ // Return a copy to prevent external modification
+ result := make([]string, len(secrets))
+ copy(result, secrets)
+
+ return result, nil
+}
+
+func (v *KeyringVault) HasSecret(key string) (bool, error) {
+ v.mu.RLock()
+ defer v.mu.RUnlock()
+
+ if err := ValidateSecretKey(key); err != nil {
+ return false, err
+ }
+
+ _, err := keyring.Get(v.service, v.secretKey(key))
+ if err != nil {
+ if errors.Is(err, keyring.ErrNotFound) {
+ return false, nil
+ }
+ return false, fmt.Errorf("failed to check secret existence: %w", err)
+ }
+
+ return true, nil
+}
+
+func (v *KeyringVault) Close() error {
+ // Keyring doesn't need explicit cleanup
+ // Just clear the in-memory metadata
+ v.mu.Lock()
+ defer v.mu.Unlock()
+
+ v.metadata = Metadata{}
+
+ return nil
+}
diff --git a/keyring_test.go b/keyring_test.go
new file mode 100644
index 0000000..cc400fe
--- /dev/null
+++ b/keyring_test.go
@@ -0,0 +1,369 @@
+package vault_test
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/flowexec/vault"
+)
+
+const testKeyringService = "flowexec-vault-test"
+
+func TestKeyringVault_New(t *testing.T) {
+ vlt, cfg, err := vault.New("test-keyring-vault",
+ vault.WithProvider(vault.ProviderTypeKeyring),
+ vault.WithKeyringService(testKeyringService),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create keyring vault: %v", err)
+ }
+ defer vlt.Close()
+
+ if vlt.ID() != "test-keyring-vault" {
+ t.Errorf("Expected vault ID 'test-keyring-vault', got '%s'", vlt.ID())
+ }
+
+ if cfg.Type != vault.ProviderTypeKeyring {
+ t.Errorf("Expected provider type '%s', got '%s'", vault.ProviderTypeKeyring, cfg.Type)
+ }
+
+ if cfg.Keyring == nil {
+ t.Fatal("Expected keyring config to be set")
+ }
+
+ if cfg.Keyring.Service != testKeyringService {
+ t.Errorf("Expected service name '%s', got '%s'", testKeyringService, cfg.Keyring.Service)
+ }
+}
+
+func TestKeyringVault_SecretOperations(t *testing.T) {
+ vlt, _, err := vault.New("test-keyring-vault",
+ vault.WithProvider(vault.ProviderTypeKeyring),
+ vault.WithKeyringService(testKeyringService),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create keyring vault: %v", err)
+ }
+ defer func() {
+ // Clean up any remaining secrets before closing
+ secrets, _ := vlt.ListSecrets()
+ for _, key := range secrets {
+ _ = vlt.DeleteSecret(key)
+ }
+ vlt.Close()
+ }()
+
+ // Test setting a secret
+ testKey := "test-keyring-key"
+ testValue := "test-keyring-value"
+ secret := vault.NewSecretValue([]byte(testValue))
+
+ err = vlt.SetSecret(testKey, secret)
+ if err != nil {
+ t.Fatalf("Failed to set secret: %v", err)
+ }
+
+ // Test getting the secret
+ retrievedSecret, err := vlt.GetSecret(testKey)
+ if err != nil {
+ t.Fatalf("Failed to get secret: %v", err)
+ }
+
+ if retrievedSecret.PlainTextString() != testValue {
+ t.Errorf("Expected secret value '%s', got '%s'", testValue, retrievedSecret.PlainTextString())
+ }
+
+ // Test HasSecret
+ exists, err := vlt.HasSecret(testKey)
+ if err != nil {
+ t.Fatalf("Failed to check if secret exists: %v", err)
+ }
+ if !exists {
+ t.Error("Expected secret to exist")
+ }
+
+ // Test ListSecrets
+ secrets, err := vlt.ListSecrets()
+ if err != nil {
+ t.Fatalf("Failed to list secrets: %v", err)
+ }
+ if len(secrets) != 1 {
+ t.Errorf("Expected 1 secret, got %d", len(secrets))
+ }
+ if secrets[0] != testKey {
+ t.Errorf("Expected secret key '%s', got '%s'", testKey, secrets[0])
+ }
+
+ // Test deleting the secret
+ err = vlt.DeleteSecret(testKey)
+ if err != nil {
+ t.Fatalf("Failed to delete secret: %v", err)
+ }
+
+ // Verify secret is gone
+ exists, err = vlt.HasSecret(testKey)
+ if err != nil {
+ t.Fatalf("Failed to check if secret exists after deletion: %v", err)
+ }
+ if exists {
+ t.Error("Expected secret to not exist after deletion")
+ }
+
+ // Test getting non-existent secret
+ _, err = vlt.GetSecret(testKey)
+ if !errors.Is(err, vault.ErrSecretNotFound) {
+ t.Errorf("Expected ErrSecretNotFound, got %v", err)
+ }
+
+ // Test deleting non-existent secret
+ err = vlt.DeleteSecret(testKey)
+ if !errors.Is(err, vault.ErrSecretNotFound) {
+ t.Errorf("Expected ErrSecretNotFound when deleting non-existent secret, got %v", err)
+ }
+}
+
+func TestKeyringVault_MultipleSecrets(t *testing.T) {
+ vlt, _, err := vault.New("test-keyring-vault-multiple",
+ vault.WithProvider(vault.ProviderTypeKeyring),
+ vault.WithKeyringService(testKeyringService),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create keyring vault: %v", err)
+ }
+ defer func() {
+ // Clean up any remaining secrets before closing
+ secrets, _ := vlt.ListSecrets()
+ for _, key := range secrets {
+ _ = vlt.DeleteSecret(key)
+ }
+ vlt.Close()
+ }()
+
+ // Set multiple secrets
+ secrets := map[string]string{
+ "API_KEY": "secret-api-key",
+ "DATABASE_URL": "postgresql://user:pass@host:5432/db",
+ "DEBUG": "true",
+ "QUOTED_VALUE": "value with spaces",
+ }
+
+ for key, value := range secrets {
+ err = vlt.SetSecret(key, vault.NewSecretValue([]byte(value)))
+ if err != nil {
+ t.Fatalf("Failed to set secret %s: %v", key, err)
+ }
+ }
+
+ // Verify all secrets can be retrieved
+ for key, expectedValue := range secrets {
+ secret, err := vlt.GetSecret(key)
+ if err != nil {
+ t.Fatalf("Failed to get secret %s: %v", key, err)
+ }
+ if secret.PlainTextString() != expectedValue {
+ t.Errorf("Expected secret value '%s' for key '%s', got '%s'", expectedValue, key, secret.PlainTextString())
+ }
+ }
+
+ // Test ListSecrets returns all keys in sorted order
+ listedKeys, err := vlt.ListSecrets()
+ if err != nil {
+ t.Fatalf("Failed to list secrets: %v", err)
+ }
+
+ expectedKeys := []string{"API_KEY", "DATABASE_URL", "DEBUG", "QUOTED_VALUE"}
+ if len(listedKeys) != len(expectedKeys) {
+ t.Errorf("Expected %d secrets, got %d", len(expectedKeys), len(listedKeys))
+ }
+
+ for i, expectedKey := range expectedKeys {
+ if i >= len(listedKeys) {
+ t.Errorf("Expected key '%s' at position %d, but list is too short", expectedKey, i)
+ continue
+ }
+ if listedKeys[i] != expectedKey {
+ t.Errorf("Expected key '%s' at position %d, got '%s'", expectedKey, i, listedKeys[i])
+ }
+ }
+
+ // Clean up - delete all secrets
+ for key := range secrets {
+ err = vlt.DeleteSecret(key)
+ if err != nil {
+ t.Fatalf("Failed to delete secret %s: %v", key, err)
+ }
+ }
+
+ // Verify all secrets are gone
+ listedKeys, err = vlt.ListSecrets()
+ if err != nil {
+ t.Fatalf("Failed to list secrets after cleanup: %v", err)
+ }
+ if len(listedKeys) != 0 {
+ t.Errorf("Expected 0 secrets after cleanup, got %d", len(listedKeys))
+ }
+}
+
+func TestKeyringVault_Persistence(t *testing.T) {
+ testKey := "persistent-keyring-key"
+ testValue := "persistent-keyring-value"
+
+ // Create first vault instance and add a secret
+ vault1, _, err := vault.New("test-keyring-persistence",
+ vault.WithProvider(vault.ProviderTypeKeyring),
+ vault.WithKeyringService(testKeyringService),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create first vault: %v", err)
+ }
+
+ err = vault1.SetSecret(testKey, vault.NewSecretValue([]byte(testValue)))
+ if err != nil {
+ t.Fatalf("Failed to set secret in first vault: %v", err)
+ }
+ vault1.Close()
+
+ // Create second vault instance with same configuration
+ vault2, _, err := vault.New("test-keyring-persistence",
+ vault.WithProvider(vault.ProviderTypeKeyring),
+ vault.WithKeyringService(testKeyringService),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create second vault: %v", err)
+ }
+ defer func() {
+ // Clean up
+ _ = vault2.DeleteSecret(testKey)
+ vault2.Close()
+ }()
+
+ // Verify secret persists
+ retrievedSecret, err := vault2.GetSecret(testKey)
+ if err != nil {
+ t.Fatalf("Failed to get secret from second vault: %v", err)
+ }
+
+ if retrievedSecret.PlainTextString() != testValue {
+ t.Errorf("Expected persisted secret value '%s', got '%s'", testValue, retrievedSecret.PlainTextString())
+ }
+
+ // Verify metadata is accessible
+ metadata := vault2.Metadata()
+ if metadata.Created.IsZero() {
+ t.Error("Expected creation time to be set")
+ }
+ if metadata.LastModified.IsZero() {
+ t.Error("Expected last modified time to be set")
+ }
+}
+
+func TestKeyringVault_Metadata(t *testing.T) {
+ vlt, _, err := vault.New("test-keyring-metadata",
+ vault.WithProvider(vault.ProviderTypeKeyring),
+ vault.WithKeyringService(testKeyringService),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create vault: %v", err)
+ }
+ defer vlt.Close()
+
+ metadata := vlt.Metadata()
+ if metadata.Created.IsZero() {
+ t.Error("Expected creation time to be set")
+ }
+ if metadata.LastModified.IsZero() {
+ t.Error("Expected last modified time to be set")
+ }
+
+ // Add a secret and verify last modified time is updated
+ oldModified := metadata.LastModified
+
+ err = vlt.SetSecret("test-key", vault.NewSecretValue([]byte("test-value")))
+ if err != nil {
+ t.Fatalf("Failed to set secret: %v", err)
+ }
+
+ newMetadata := vlt.Metadata()
+ if !newMetadata.LastModified.After(oldModified) {
+ t.Error("Expected last modified time to be updated after setting secret")
+ }
+
+ // Clean up
+ _ = vlt.DeleteSecret("test-key")
+}
+
+func TestKeyringVault_InvalidKeyValidation(t *testing.T) {
+ vlt, _, err := vault.New("test-keyring-validation",
+ vault.WithProvider(vault.ProviderTypeKeyring),
+ vault.WithKeyringService(testKeyringService),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create vault: %v", err)
+ }
+ defer vlt.Close()
+
+ // Test empty key
+ err = vlt.SetSecret("", vault.NewSecretValue([]byte("value")))
+ if err == nil {
+ t.Error("Expected error when setting secret with empty key")
+ }
+
+ _, err = vlt.GetSecret("")
+ if err == nil {
+ t.Error("Expected error when getting secret with empty key")
+ }
+
+ err = vlt.DeleteSecret("")
+ if err == nil {
+ t.Error("Expected error when deleting secret with empty key")
+ }
+
+ _, err = vlt.HasSecret("")
+ if err == nil {
+ t.Error("Expected error when checking secret with empty key")
+ }
+}
+
+func TestKeyringVault_SortedOutput(t *testing.T) {
+ vlt, _, err := vault.New("test-keyring-sorted",
+ vault.WithProvider(vault.ProviderTypeKeyring),
+ vault.WithKeyringService(testKeyringService),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create vault: %v", err)
+ }
+ defer func() {
+ // Clean up any remaining secrets before closing
+ secrets, _ := vlt.ListSecrets()
+ for _, key := range secrets {
+ _ = vlt.DeleteSecret(key)
+ }
+ vlt.Close()
+ }()
+
+ // Add secrets in non-alphabetical order
+ secretKeys := []string{"zebra", "alpha", "beta", "gamma"}
+ for _, key := range secretKeys {
+ err = vlt.SetSecret(key, vault.NewSecretValue([]byte("value-"+key)))
+ if err != nil {
+ t.Fatalf("Failed to set secret %s: %v", key, err)
+ }
+ }
+
+ // List secrets and verify they are sorted
+ listedKeys, err := vlt.ListSecrets()
+ if err != nil {
+ t.Fatalf("Failed to list secrets: %v", err)
+ }
+
+ expectedOrder := []string{"alpha", "beta", "gamma", "zebra"}
+ if len(listedKeys) != len(expectedOrder) {
+ t.Fatalf("Expected %d keys, got %d", len(expectedOrder), len(listedKeys))
+ }
+
+ for i, expected := range expectedOrder {
+ if listedKeys[i] != expected {
+ t.Errorf("Expected key at position %d to be '%s', got '%s'", i, expected, listedKeys[i])
+ }
+ }
+}
diff --git a/vault.go b/vault.go
index ea66f17..6edf241 100644
--- a/vault.go
+++ b/vault.go
@@ -39,6 +39,9 @@ func New(id string, opts ...Option) (Provider, *Config, error) {
case ProviderTypeAES256:
provider, err := NewAES256Vault(config)
return provider, config, err
+ case ProviderTypeKeyring:
+ provider, err := NewKeyringVault(config)
+ return provider, config, err
case ProviderTypeUnencrypted:
provider, err := NewUnencryptedVault(config)
return provider, config, err
@@ -85,6 +88,16 @@ func WithUnencryptedPath(path string) Option {
}
}
+// WithKeyringService sets the keyring service name
+func WithKeyringService(service string) Option {
+ return func(c *Config) {
+ if c.Keyring == nil {
+ c.Keyring = &KeyringConfig{}
+ }
+ c.Keyring.Service = service
+ }
+}
+
// WithLocalPath sets the local vault storage path (works for Age, AES, and Unencrypted based on provider type)
func WithLocalPath(path string) Option {
return func(c *Config) {
From d80d4bdc5a84613e3b25cbdaccec4b1c31a43dfe Mon Sep 17 00:00:00 2001
From: Jahvon Dockery
Date: Sun, 10 Aug 2025 21:34:24 -0400
Subject: [PATCH 3/6] feat: external vault provider
---
.claude/settings.local.json | 3 +-
.golangci.yaml | 3 +
README.md | 133 +++++++--
config.go | 39 ++-
examples/README.md | 86 ++++++
examples/main.go | 158 +++++++++++
examples/providers/1password.json | 31 ++
examples/providers/aws-ssm.json | 34 +++
examples/providers/bitwarden.json | 31 ++
examples/providers/pass.json | 33 +++
external.go | 454 +++++++++++++++++++++++++++++-
external_test.go | 432 ++++++++++++++++++++++++++++
go.mod | 9 +-
go.sum | 21 +-
local.go | 1 +
vault.go | 3 +-
16 files changed, 1423 insertions(+), 48 deletions(-)
create mode 100644 examples/README.md
create mode 100644 examples/main.go
create mode 100644 examples/providers/1password.json
create mode 100644 examples/providers/aws-ssm.json
create mode 100644 examples/providers/bitwarden.json
create mode 100644 examples/providers/pass.json
create mode 100644 external_test.go
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index cf6b2b1..90a0d98 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -4,7 +4,8 @@
"Bash(go test:*)",
"Bash(go build:*)",
"mcp__flow__execute",
- "mcp__flow__list_executables"
+ "mcp__flow__list_executables",
+ "Bash(go fmt:*)"
],
"deny": []
}
diff --git a/.golangci.yaml b/.golangci.yaml
index b190250..22a8868 100644
--- a/.golangci.yaml
+++ b/.golangci.yaml
@@ -85,6 +85,9 @@ linters:
- linters:
- gocritic
source: //noinspection
+ - linters: [gocritic]
+ text: "exitAfterDefer: (.*)"
+ path: main\.go
- linters:
- lll
path: mocks\.go
diff --git a/README.md b/README.md
index 062d547..8e228c9 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,15 @@
-A Go package for secure secret storage with multiple encryption backends. Made for [flow](https://github.com/jahvon/flow) but can be used independently.
+A flexible Go library for secure secret management with multiple backend providers. Made for [flow](https://github.com/jahvon/flow) but can be used independently.
+
+## Features
+
+- **Multiple Provider Support**: Choose from local encrypted storage, system keyring, or external CLI tools
+- **Pluggable Architecture**: Easy to extend with custom providers
+- **Type Safety**: Strong typing for secrets with secure memory handling
+- **Thread Safe**: Concurrent access protection with read/write mutexes
+- **Comprehensive API**: Full CRUD operations plus metadata and existence checks
## Quick Start
@@ -45,36 +53,125 @@ func main() {
}
```
-## Providers
+## Provider Types
+
+### Local Encrypted Providers
-### AES256 Provider
+#### AES256 Provider
+Stores secrets in an AES-256 encrypted file with configurable key sources.
-Symmetric encryption using AES-256. Best for when you want a single encryption key shared across users / systems.
+```go
+provider, _, err := vault.New("my-vault",
+ vault.WithProvider(vault.ProviderTypeAES256),
+ vault.WithAESPath("~/secrets.vault"),
+)
+```
**Key Generation:**
```go
key, err := vault.GenerateEncryptionKey()
-if err != nil {
- panic(err)
-}
-// Store this key securely and configure vault to use it
+// Store this key securely (environment variable, HSM, etc.)
```
-### Age Provider
+#### Age Provider
+Uses the [age encryption tool](https://age-encryption.org/) with public key cryptography.
-Asymmetric encryption using the [age encryption format](https://github.com/FiloSottile/age). Best for when you may have multiple users or need the ability to add/remove recipients.
+```go
+provider, _, err := vault.New("my-vault",
+ vault.WithProvider(vault.ProviderTypeAge),
+ vault.WithAgePath("~/secrets.age"),
+)
+```
**Key Generation:**
```bash
-# Generate age key pair - see https://github.com/FiloSottile/age for details
-age-keygen -o key.txt
-# Public key: age1ql3blv6a5y...
-# Private key in key.txt
+age-keygen -o ~/.age/identity.txt
+# Add recipients to vault configuration
+```
+
+#### Keyring Provider
+Integrates with the operating system's secure keyring.
+
+```go
+provider, _, err := vault.New("my-vault",
+ vault.WithProvider(vault.ProviderTypeKeyring),
+ vault.WithKeyringService("my-app-secrets"),
+)
+```
+
+No additional setup required - uses OS authentication.
+
+#### Unencrypted Provider
+Stores secrets in plain text JSON files.
+
+```go
+provider, _, err := vault.New("my-vault",
+ vault.WithProvider(vault.ProviderTypeUnencrypted),
+ vault.WithUnencryptedPath("~/dev-secrets.json"),
+)
```
-## Encrypted Files
+### External CLI Providers
-Both vault types create a single encrypted file at the specified path:
+#### External Provider
+Integrates with any CLI tool for secret management. Supports popular tools like Bitwarden, 1Password, HashiCorp Vault, AWS SSM, and more.
-- **AES256**: `vault-{id}.enc`
-- **Age**: `vault-{id}.age`
+```go
+config := &vault.Config{
+ ID: "bitwarden",
+ Type: vault.ProviderTypeExternal,
+ External: &vault.ExternalConfig{
+ Get: vault.CommandConfig{
+ CommandTemplate: "bw get password {{key}}",
+ },
+ Set: vault.CommandConfig{
+ CommandTemplate: "bw create item --name {{key}} --password {{value}}",
+ },
+ // ... other operations
+ },
+}
+
+provider, err := vault.NewExternalVaultProvider(config)
+```
+
+**External Provider Examples**
+
+Ready-to-use configurations for popular CLI tools are available in the [`examples/`](./examples/) directory:
+
+- **[Bitwarden](./examples/providers/bitwarden.json)**
+- **[1Password](./examples/providers/1password.json)**
+- **[AWS SSM](./examples/providers/aws-ssm.json)**
+- **[pass](./examples/providers/pass.json)**
+
+See the [examples README](./examples/README.md) for detailed setup instructions.
+
+## Usage
+
+### Basic Operations
+
+```go
+// Store a secret
+secret := vault.NewSecretValue([]byte("my-secret-value"))
+err = provider.SetSecret("api-key", secret)
+
+// Retrieve the secret
+retrieved, err := provider.GetSecret("api-key")
+fmt.Println("Secret:", retrieved.PlainTextString())
+
+// List all secrets
+secrets, _ := provider.ListSecrets()
+
+// Check if secret exists
+exists, _ := provider.HasSecret("api-key")
+
+// Get vault metadata
+metadata := provider.Metadata()
+```
+
+### Configuration from File
+
+```go
+// Load configuration from JSON
+config, err := vault.LoadConfigJSON("vault-config.json")
+provider, _, err := vault.New(config.ID, vault.WithProvider(config.Type))
+```
diff --git a/config.go b/config.go
index e591b4f..b25eccc 100644
--- a/config.go
+++ b/config.go
@@ -5,7 +5,6 @@ import (
"fmt"
"os"
"path/filepath"
- "time"
)
type ProviderType string
@@ -181,33 +180,45 @@ func (c *AesConfig) Validate() error {
return nil
}
-// CommandSet defines the command templates for external vault operations
-type CommandSet struct {
- Get string `json:"get"`
- Set string `json:"set"`
- Delete string `json:"delete"`
- List string `json:"list"`
- Exists string `json:"exists,omitempty"`
+// CommandConfig represents a command template to be executed with its arguments
+type CommandConfig struct {
+ // CommandTemplate for building command arguments
+ CommandTemplate string `json:"cmd"`
+ // OutputTemplate for parsing command output
+ OutputTemplate string `json:"output,omitempty"`
+ // InputTemplate for providing input to the command
+ InputTemplate string `json:"input,omitempty"`
}
// ExternalConfig contains external (cli command-based) vault configuration
type ExternalConfig struct {
- // Command templates for operations
- Commands CommandSet `json:"commands"`
+ // Get CommandConfig for the get operation
+ Get CommandConfig `json:"get,omitempty"`
+ // Set CommandConfig for the set operation
+ Set CommandConfig `json:"set,omitempty"`
+ // Delete CommandConfig for the delete operation
+ Delete CommandConfig `json:"delete,omitempty"`
+ // List CommandConfig for the list operation
+ List CommandConfig `json:"list,omitempty"`
+ ListSeparator string `json:"separator,omitempty"`
+ // Exists CommandConfig for the exists operation
+ Exists CommandConfig `json:"exists,omitempty"`
+ // Metadata CommandConfig for the metadata operation
+ Metadata CommandConfig `json:"metadata,omitempty"`
// Environment variables for commands
Environment map[string]string `json:"environment,omitempty"`
- // Timeout for command execution
- Timeout time.Duration `json:"timeout,omitempty"`
+ // Timeout duration string for command execution
+ Timeout string `json:"timeout,omitempty"`
// WorkingDir for command execution
WorkingDir string `json:"working_dir,omitempty"`
}
func (c *ExternalConfig) Validate() error {
- if c.Commands.Get == "" || c.Commands.Set == "" {
- return fmt.Errorf("%w: get and set commands are required for external vault", ErrInvalidConfig)
+ if c.Get.CommandTemplate == "" || c.Set.CommandTemplate == "" {
+ return fmt.Errorf("%w: get and set args template required for external vault", ErrInvalidConfig)
}
return nil
}
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 0000000..1254e00
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,86 @@
+# External Vault Provider Examples
+
+This directory contains ready-to-use configurations for popular CLI tools.
+
+## Available Configurations
+
+- **[Bitwarden](./providers/bitwarden.json)**
+- **[1Password](./providers/1password.json)**
+- **[pass](./providers/pass.json)**
+- **[AWS SSM Parameter Store](./providers/aws-ssm.json)**
+
+## Quick Start
+
+```bash
+# Test a configuration
+./test-provider.sh providers/bitwarden.json
+
+# Run the Go example
+go run main.go providers/pass.json
+```
+
+## Setup Instructions
+
+### Authentication Requirements
+
+Each tool requires prior authentication:
+
+- **Bitwarden**: `bw login && bw unlock`
+- **1Password**: `op signin`
+- **AWS SSM**: `aws configure`
+- **pass**: Configure GPG keys
+
+### Environment Variables
+
+| Provider | Required Variables |
+|----------|-------------------|
+| Bitwarden | `BW_SESSION` |
+| 1Password | `OP_SERVICE_ACCOUNT_TOKEN` |
+| AWS SSM | `AWS_REGION` (+ credentials) |
+| pass | `PASSWORD_STORE_DIR` (optional) |
+
+## Configuration Structure
+
+Each configuration follows this pattern:
+
+```json
+{
+ "id": "provider-name",
+ "type": "external",
+ "external": {
+ "cmd": "cli-command",
+ "get": {
+ "cmd": "subcommand {{key}}",
+ "output": "{{output}}"
+ },
+ "set": {
+ "cmd": "subcommand {{key}} {{value}}"
+ },
+ "list": {
+ "cmd": "list-subcommand"
+ },
+ "delete": {
+ "cmd": "delete-subcommand {{key}}"
+ },
+ "exists": {
+ "cmd": "check-subcommand {{key}}"
+ },
+ "metadata": {
+ "cmd": "status-subcommand"
+ },
+ "environment": {
+ "ENV_VAR": "$ENV_VAR"
+ },
+ "timeout": "30s"
+ }
+}
+```
+
+## Template Variables
+
+Available in `cmd` and `output` fields:
+
+- `{{key}}` - The secret key/name
+- `{{value}}` - The secret value (for set operations)
+- `{{env["VariableName"]}}`- Environment variable value
+- `{{output}}` - Raw command output (for output templates)
diff --git a/examples/main.go b/examples/main.go
new file mode 100644
index 0000000..56dd613
--- /dev/null
+++ b/examples/main.go
@@ -0,0 +1,158 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/flowexec/vault"
+)
+
+func main() {
+ if len(os.Args) < 2 {
+ fmt.Println("Usage: go run main.go ")
+ fmt.Println("Example: go run main.go providers/bitwarden.json")
+ fmt.Println()
+ listProviders()
+ os.Exit(1)
+ }
+
+ configPath := os.Args[1]
+ if _, err := os.Stat(configPath); os.IsNotExist(err) {
+ fmt.Printf("Error: Configuration file '%s' not found\n", configPath)
+ os.Exit(1)
+ }
+
+ fmt.Printf("Testing vault provider configuration: %s\n", configPath)
+ fmt.Println()
+
+ config, err := vault.LoadConfigJSON(configPath)
+ if err != nil {
+ log.Fatalf("%v", err)
+ }
+
+ fmt.Printf("Provider ID: %s\n", config.ID)
+ fmt.Println()
+
+ if err := checkEnvironmentVariables(configPath); err != nil {
+ fmt.Printf("Warning: Could not check environment variables: %v\n", err)
+ }
+ fmt.Println()
+
+ provider, _, err := vault.New(config.ID,
+ vault.WithProvider(vault.ProviderTypeExternal),
+ vault.WithExternalConfig(config.External),
+ )
+ if err != nil {
+ log.Fatalf("%v", err)
+ }
+ defer provider.Close()
+
+ fmt.Printf("Using vault provider: %s\n", provider.ID())
+
+ fmt.Println("Setting test secret...")
+ testSecret := vault.NewSecretValue([]byte("test-secret-value-123"))
+ err = provider.SetSecret("test-key", testSecret)
+ if err != nil {
+ log.Fatalf("%v", err)
+ } else {
+ fmt.Println("Secret set successfully")
+ }
+
+ fmt.Println("Checking if secret exists...")
+ exists, err := provider.HasSecret("test-key")
+ if err != nil {
+ log.Fatalf("%v", err)
+ } else {
+ fmt.Printf("Secret exists: %t\n", exists)
+ }
+
+ fmt.Println("Retrieving secret...")
+ retrievedSecret, err := provider.GetSecret("test-key")
+ if err != nil {
+ log.Fatalf("%v", err)
+ } else {
+ fmt.Printf("Retrieved secret: %s\n", retrievedSecret.String())
+ fmt.Printf("Secret length: %d characters\n", len(retrievedSecret.PlainTextString()))
+ }
+
+ fmt.Println("Listing all secrets...")
+ secrets, err := provider.ListSecrets()
+ if err != nil {
+ log.Fatalf("%v", err)
+ } else {
+ fmt.Printf("Found %d secrets:\n", len(secrets))
+ for i, secret := range secrets {
+ fmt.Printf(" %d. %s\n", i+1, secret)
+ }
+ }
+
+ fmt.Println("Getting vault metadata...")
+ metadata := provider.Metadata()
+ fmt.Printf("Metadata: %s\n", metadata.RawData)
+
+ fmt.Println("Cleaning up test secret...")
+ err = provider.DeleteSecret("test-key")
+ if err != nil {
+ log.Fatalf("%v", err)
+ } else {
+ fmt.Println("Test secret deleted successfully")
+ }
+
+ fmt.Println("Testing completed successfully")
+}
+
+func listProviders() {
+ fmt.Println("Available providers:")
+ matches, err := filepath.Glob("providers/*.json")
+ if err != nil {
+ fmt.Printf("Error listing providers: %v\n", err)
+ return
+ }
+ for _, match := range matches {
+ fmt.Printf(" %s\n", strings.TrimPrefix(match, "providers/"))
+ }
+}
+
+func checkEnvironmentVariables(configPath string) error {
+ file, err := os.Open(configPath)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+
+ var config map[string]interface{}
+ if err := json.NewDecoder(file).Decode(&config); err != nil {
+ return err
+ }
+
+ fmt.Println("Checking required environment variables:")
+
+ external, ok := config["external"].(map[string]interface{})
+ if !ok {
+ fmt.Println(" No external configuration found")
+ return nil
+ }
+
+ environment, ok := external["environment"].(map[string]interface{})
+ if !ok {
+ fmt.Println(" No environment variables required")
+ return nil
+ }
+
+ for _, value := range environment {
+ if valueStr, isStr := value.(string); isStr && strings.HasPrefix(valueStr, "$") {
+ envVar := strings.TrimPrefix(valueStr, "$")
+ if os.Getenv(envVar) != "" {
+ fmt.Printf(" %s is set\n", envVar)
+ } else {
+ fmt.Printf(" Warning: %s is not set\n", envVar)
+ }
+ }
+ }
+ fmt.Println()
+ return nil
+}
diff --git a/examples/providers/1password.json b/examples/providers/1password.json
new file mode 100644
index 0000000..fac2bd2
--- /dev/null
+++ b/examples/providers/1password.json
@@ -0,0 +1,31 @@
+{
+ "id": "onepassword",
+ "type": "external",
+ "external": {
+ "get": {
+ "cmd": "op read \"op://Private/{{key}}/password\"",
+ "output": "{{output}}"
+ },
+ "set": {
+ "cmd": "op item create --category Login --title {{key}} password={{value}} --vault Private"
+ },
+ "delete": {
+ "cmd": "op item delete {{key}} --vault Private"
+ },
+ "list": {
+ "cmd": "op item list --vault Private --format json",
+ "output": "{{ map(fromJSON(output), {.title}) | join(\"\\n\") }}"
+ },
+ "exists": {
+ "cmd": "op item get {{key}} --vault Private"
+ },
+ "metadata": {
+ "cmd": "op account list",
+ "output": "Account: {{output}}"
+ },
+ "environment": {
+ "OP_SERVICE_ACCOUNT_TOKEN": "$OP_SERVICE_ACCOUNT_TOKEN"
+ },
+ "timeout": "30s"
+ }
+}
\ No newline at end of file
diff --git a/examples/providers/aws-ssm.json b/examples/providers/aws-ssm.json
new file mode 100644
index 0000000..67b482e
--- /dev/null
+++ b/examples/providers/aws-ssm.json
@@ -0,0 +1,34 @@
+{
+ "id": "aws-ssm",
+ "type": "external",
+ "external": {
+ "get": {
+ "cmd": "aws ssm get-parameter --name /{{key}} --with-decryption --query Parameter.Value --output text",
+ "output": "{{output}}"
+ },
+ "set": {
+ "cmd": "aws ssm put-parameter --name /{{key}} --value {{value}} --type SecureString --overwrite"
+ },
+ "delete": {
+ "cmd": "aws ssm delete-parameter --name /{{key}}"
+ },
+ "list": {
+ "cmd": "aws ssm describe-parameters --query Parameters[].Name --output text",
+ "output": "{{output}}"
+ },
+ "listSeparator": "\t",
+ "exists": {
+ "cmd": "aws ssm get-parameter --name /{{key}}"
+ },
+ "metadata": {
+ "cmd": "aws sts get-caller-identity --output json",
+ "output": "Account: {{fromJSON(output)[\"Account\"]}}, User: {{fromJSON(output)[\"Arn\"]}}"
+ },
+ "environment": {
+ "AWS_REGION": "$AWS_REGION",
+ "AWS_ACCESS_KEY_ID": "$AWS_ACCESS_KEY_ID",
+ "AWS_SECRET_ACCESS_KEY": "$AWS_SECRET_ACCESS_KEY"
+ },
+ "timeout": "60s"
+ }
+}
\ No newline at end of file
diff --git a/examples/providers/bitwarden.json b/examples/providers/bitwarden.json
new file mode 100644
index 0000000..fb5bde7
--- /dev/null
+++ b/examples/providers/bitwarden.json
@@ -0,0 +1,31 @@
+{
+ "id": "bitwarden",
+ "type": "external",
+ "external": {
+ "get": {
+ "cmd": "bw get password {{key}}",
+ "output": "{{output}}"
+ },
+ "set": {
+ "cmd": "if bw get item \"{{key}}\" >/dev/null 2>&1; then bw get item \"{{key}}\" | jq '.login.password=\"{{value}}\"' | bw encode | bw edit item $(bw get item \"{{key}}\" | jq -r .id); else echo '{\"object\":\"item\",\"type\":1,\"name\":\"{{key}}\",\"login\":{\"username\":\"{{key}}\",\"password\":\"{{value}}\"}}' | bw encode | bw create item; fi"
+ },
+ "delete": {
+ "cmd": "bw delete item $(bw get item {{key}} | jq -r .id)"
+ },
+ "list": {
+ "cmd": "bw list items --search \"\" --pretty",
+ "output": "{{ map(fromJSON(output), {.name}) | join(\"\\n\") }}"
+ },
+ "exists": {
+ "cmd": "bw get item {{key}}"
+ },
+ "metadata": {
+ "cmd": "bw status",
+ "output": "Status: {{output}}"
+ },
+ "environment": {
+ "BW_SESSION": "$BW_SESSION"
+ },
+ "timeout": "30s"
+ }
+}
\ No newline at end of file
diff --git a/examples/providers/pass.json b/examples/providers/pass.json
new file mode 100644
index 0000000..b8f6d7a
--- /dev/null
+++ b/examples/providers/pass.json
@@ -0,0 +1,33 @@
+{
+ "id": "pass",
+ "type": "external",
+ "external": {
+ "get": {
+ "cmd": "pass show {{key}}",
+ "output": "{{output}}"
+ },
+ "set": {
+ "cmd": "pass insert -e {{key}}",
+ "input": "{{value}}"
+ },
+ "delete": {
+ "cmd": "pass rm -f {{key}}"
+ },
+ "list": {
+ "cmd": "pass ls",
+ "output": "{{output}}"
+ },
+ "exists": {
+ "cmd": "pass show {{key}}"
+ },
+ "metadata": {
+ "cmd": "pass git log --oneline -1",
+ "output": "Last change: {{output}}"
+ },
+ "environment": {
+ "PASSWORD_STORE_DIR": "$PASSWORD_STORE_DIR",
+ "GPG_TTY": "$(tty)"
+ },
+ "timeout": "30s"
+ }
+}
\ No newline at end of file
diff --git a/external.go b/external.go
index 57c79e0..aab2425 100644
--- a/external.go
+++ b/external.go
@@ -1,32 +1,472 @@
package vault
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/jahvon/expression"
+ "mvdan.cc/sh/v3/expand"
+ "mvdan.cc/sh/v3/interp"
+ "mvdan.cc/sh/v3/syntax"
+)
+
type ExternalVaultProvider struct {
+ ctx context.Context
+ mu sync.RWMutex
+ id string
+ execute func(ctx context.Context, cmd, input, dir string, envList []string) (string, error)
+
+ cfg *ExternalConfig
+}
+
+func NewExternalVaultProvider(cfg *Config) (*ExternalVaultProvider, error) {
+ if cfg.External == nil {
+ return nil, fmt.Errorf("external configuration is required")
+ }
+
+ vault := &ExternalVaultProvider{
+ ctx: context.Background(),
+ id: cfg.ID,
+ cfg: cfg.External,
+ execute: execute,
+ }
+
+ return vault, nil
}
func (v *ExternalVaultProvider) ID() string {
- panic("not implemented yet")
+ return v.id
}
-func (v *ExternalVaultProvider) GetSecret(_ string) (Secret, error) {
- panic("not implemented yet")
+func (v *ExternalVaultProvider) GetSecret(key string) (Secret, error) {
+ v.mu.RLock()
+ defer v.mu.RUnlock()
+
+ if err := ValidateSecretKey(key); err != nil {
+ return nil, err
+ }
+
+ if v.cfg.Get.CommandTemplate == "" {
+ return nil, fmt.Errorf("get operation not configured")
+ }
+
+ cmd, err := v.renderCmdTemplate(v.cfg.Get.CommandTemplate, key)
+ if err != nil {
+ return nil, fmt.Errorf("failed to render get cmd: %w", err)
+ }
+
+ var input string
+ if v.cfg.Get.InputTemplate != "" {
+ input, err = v.renderInputTemplate(v.cfg.Get.InputTemplate, key)
+ if err != nil {
+ return nil, fmt.Errorf("failed to render input template: %w", err)
+ }
+ }
+
+ output, err := v.executeCommand(cmd, input)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get secret: %w", err)
+ }
+
+ var secretValue string
+ if v.cfg.Get.OutputTemplate != "" {
+ secretValue, err = v.renderOutputTemplate(v.cfg.Get.OutputTemplate, output)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse output: %w", err)
+ }
+ } else {
+ secretValue = strings.TrimSpace(output)
+ }
+
+ return NewSecretValue([]byte(secretValue)), nil
}
func (v *ExternalVaultProvider) SetSecret(key string, value Secret) error {
- panic("not implemented yet")
+ v.mu.Lock()
+ defer v.mu.Unlock()
+
+ if err := ValidateSecretKey(key); err != nil {
+ return err
+ }
+
+ if v.cfg.Set.CommandTemplate == "" {
+ return fmt.Errorf("set operation not configured")
+ }
+
+ cmd, err := v.renderCmdTemplateWithValue(v.cfg.Set.CommandTemplate, key, value.PlainTextString())
+ if err != nil {
+ return fmt.Errorf("failed to render set cmd: %w", err)
+ }
+
+ var input string
+ if v.cfg.Set.InputTemplate != "" {
+ input, err = v.renderInputTemplate(v.cfg.Get.InputTemplate, key)
+ if err != nil {
+ return fmt.Errorf("failed to render input template: %w", err)
+ }
+ }
+
+ out, err := v.executeCommand(cmd, input)
+ if err != nil {
+ return fmt.Errorf("failed to set secret: %w stdErr: %s", err, out)
+ }
+
+ return nil
}
func (v *ExternalVaultProvider) DeleteSecret(key string) error {
- panic("not implemented yet")
+ v.mu.Lock()
+ defer v.mu.Unlock()
+
+ if err := ValidateSecretKey(key); err != nil {
+ return err
+ }
+
+ if v.cfg.Delete.CommandTemplate == "" {
+ return fmt.Errorf("delete operation not configured")
+ }
+
+ cmd, err := v.renderCmdTemplate(v.cfg.Delete.CommandTemplate, key)
+ if err != nil {
+ return fmt.Errorf("failed to render delete cmd: %w", err)
+ }
+
+ var input string
+ if v.cfg.Delete.InputTemplate != "" {
+ input, err = v.renderInputTemplate(v.cfg.Get.InputTemplate, key)
+ if err != nil {
+ return fmt.Errorf("failed to render input template: %w", err)
+ }
+ }
+
+ if _, err := v.executeCommand(cmd, input); err != nil {
+ return fmt.Errorf("failed to delete secret: %w", err)
+ }
+
+ return nil
}
func (v *ExternalVaultProvider) ListSecrets() ([]string, error) {
- panic("not implemented yet")
+ v.mu.RLock()
+ defer v.mu.RUnlock()
+
+ if v.cfg.List.CommandTemplate == "" {
+ return nil, fmt.Errorf("list operation not configured")
+ }
+
+ cmd, err := v.renderCmdTemplate(v.cfg.List.CommandTemplate, "")
+ if err != nil {
+ return nil, fmt.Errorf("failed to render list cmd: %w", err)
+ }
+
+ var input string
+ if v.cfg.List.InputTemplate != "" {
+ input, err = v.renderInputTemplate(v.cfg.Get.InputTemplate, "")
+ if err != nil {
+ return nil, fmt.Errorf("failed to render input template: %w", err)
+ }
+ }
+
+ output, err := v.executeCommand(cmd, input)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list secrets: %w", err)
+ }
+
+ var secretsList string
+ if v.cfg.List.OutputTemplate != "" {
+ secretsList, err = v.renderOutputTemplate(v.cfg.List.OutputTemplate, output)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse list output: %w", err)
+ }
+ } else {
+ secretsList = strings.TrimSpace(output)
+ }
+
+ if secretsList == "" {
+ return []string{}, nil
+ }
+
+ sep := v.cfg.ListSeparator
+ if sep == "" {
+ sep = "\n"
+ }
+ secrets := strings.Split(secretsList, sep)
+ var result []string
+ for _, secret := range secrets {
+ secret = strings.TrimSpace(secret)
+ if secret != "" {
+ result = append(result, secret)
+ }
+ }
+
+ return result, nil
}
func (v *ExternalVaultProvider) HasSecret(key string) (bool, error) {
- panic("not implemented yet")
+ v.mu.RLock()
+ defer v.mu.RUnlock()
+
+ if err := ValidateSecretKey(key); err != nil {
+ return false, err
+ }
+
+ if v.cfg.Exists.CommandTemplate != "" {
+ cmd, err := v.renderCmdTemplate(v.cfg.Exists.CommandTemplate, key)
+ if err != nil {
+ return false, fmt.Errorf("failed to render exists cmd: %w", err)
+ }
+
+ var input string
+ if v.cfg.Exists.InputTemplate != "" {
+ input, err = v.renderInputTemplate(v.cfg.Exists.InputTemplate, key)
+ if err != nil {
+ return false, fmt.Errorf("failed to render input template: %w", err)
+ }
+ }
+
+ _, err = v.executeCommand(cmd, input)
+ // typically, exists commands return non-zero exit code if secret doesn't exist
+ return err == nil, nil
+ }
+
+ _, err := v.GetSecret(key)
+ if err != nil {
+ if strings.Contains(err.Error(), "not found") ||
+ strings.Contains(err.Error(), "not exist") ||
+ strings.Contains(err.Error(), "not in") {
+ return false, nil
+ }
+ return false, err
+ }
+ return true, nil
}
func (v *ExternalVaultProvider) Close() error {
return nil
}
+
+func (v *ExternalVaultProvider) SetExecutionFunc(
+ fn func(ctx context.Context, cmd, input, dir string, envList []string) (string, error),
+) {
+ v.execute = fn
+}
+
+func (v *ExternalVaultProvider) Metadata() Metadata {
+ v.mu.RLock()
+ defer v.mu.RUnlock()
+
+ if v.cfg.Metadata.CommandTemplate == "" {
+ return Metadata{}
+ }
+
+ cmd, err := v.renderCmdTemplate(v.cfg.Metadata.CommandTemplate, "")
+ if err != nil {
+ return Metadata{}
+ }
+ var input string
+ if v.cfg.List.InputTemplate != "" {
+ input, err = v.renderInputTemplate(v.cfg.Metadata.InputTemplate, "")
+ if err != nil {
+ return Metadata{}
+ }
+ }
+
+ output, err := v.executeCommand(cmd, input)
+ if err != nil {
+ return Metadata{}
+ }
+
+ var metadataOutput string
+ if v.cfg.Metadata.OutputTemplate != "" {
+ metadataOutput, err = v.renderOutputTemplate(v.cfg.Metadata.OutputTemplate, output)
+ if err != nil {
+ return Metadata{}
+ }
+ } else {
+ metadataOutput = strings.TrimSpace(output)
+ }
+
+ return Metadata{RawData: metadataOutput}
+}
+
+func (v *ExternalVaultProvider) executeCommand(cmd, input string) (string, error) {
+ ctx := v.ctx
+ if v.cfg.Timeout != "" {
+ var cancel context.CancelFunc
+ dur, parseErr := time.ParseDuration(v.cfg.Timeout)
+ if parseErr != nil {
+ return "", fmt.Errorf("invalid timeout duration: %w", parseErr)
+ }
+ ctx, cancel = context.WithTimeout(v.ctx, dur)
+ defer cancel()
+ }
+
+ output, runErr := v.execute(ctx, cmd, input, v.cfg.WorkingDir, v.environmentToSlice())
+ if runErr != nil {
+ return "", fmt.Errorf("command failed: %w, stderr: %s", runErr, output)
+ }
+
+ return output, nil
+}
+
+func (v *ExternalVaultProvider) environmentToSlice() []string {
+ var envSlice []string
+ for key, value := range expandEnv(v.cfg.Environment) {
+ envSlice = append(envSlice, fmt.Sprintf("%s=%s", key, value))
+ }
+ return envSlice
+}
+
+func (v *ExternalVaultProvider) renderCmdTemplate(template, key string) (string, error) {
+ data := expression.Data{
+ "env": expandEnv(v.cfg.Environment),
+ "key": key,
+ "ref": key,
+ "id": key,
+ "name": key,
+ "template": template,
+ }
+
+ template = os.ExpandEnv(template)
+ tmpl := expression.NewTemplate(fmt.Sprintf("%s-args-template", v.id), data)
+ err := tmpl.Parse(template)
+ if err != nil {
+ return "", fmt.Errorf("parsing args template: %w", err)
+ }
+
+ result, err := tmpl.ExecuteToString()
+ if err != nil {
+ return "", fmt.Errorf("evaluating args template: %w", err)
+ }
+ return result, nil
+}
+
+func (v *ExternalVaultProvider) renderCmdTemplateWithValue(template, key, value string) (string, error) {
+ data := expression.Data{
+ "env": expandEnv(v.cfg.Environment),
+ "key": key,
+ "ref": key,
+ "id": key,
+ "name": key,
+ "value": value,
+ "password": value,
+ "template": template,
+ }
+
+ template = os.ExpandEnv(template)
+ tmpl := expression.NewTemplate(fmt.Sprintf("%s-args-template", v.id), data)
+ err := tmpl.Parse(template)
+ if err != nil {
+ return "", fmt.Errorf("parsing args template: %w", err)
+ }
+
+ result, err := tmpl.ExecuteToString()
+ if err != nil {
+ return "", fmt.Errorf("evaluating args template: %w", err)
+ }
+ return result, nil
+}
+
+func (v *ExternalVaultProvider) renderInputTemplate(template, input string) (string, error) {
+ data := expression.Data{
+ "env": expandEnv(v.cfg.Environment),
+ "input": input,
+ "template": template,
+ }
+
+ template = os.ExpandEnv(template)
+ tmpl := expression.NewTemplate(fmt.Sprintf("%s-input-template", v.id), data)
+ err := tmpl.Parse(template)
+ if err != nil {
+ return "", fmt.Errorf("parsing input template: %w", err)
+ }
+
+ result, err := tmpl.ExecuteToString()
+ if err != nil {
+ return "", fmt.Errorf("evaluating input template: %w", err)
+ }
+ return result, nil
+}
+
+func (v *ExternalVaultProvider) renderOutputTemplate(template, output string) (string, error) {
+ data := expression.Data{
+ "env": expandEnv(v.cfg.Environment),
+ "output": output,
+ "template": template,
+ }
+
+ template = os.ExpandEnv(template)
+ tmpl := expression.NewTemplate(fmt.Sprintf("%s-output-template", v.id), data)
+ err := tmpl.Parse(template)
+ if err != nil {
+ return "", fmt.Errorf("parsing output template: %w", err)
+ }
+
+ result, err := tmpl.ExecuteToString()
+ if err != nil {
+ return "", fmt.Errorf("evaluating output template: %w", err)
+ }
+ return result, nil
+}
+
+func execute(ctx context.Context, cmd, input, dir string, envList []string) (string, error) {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ parser := syntax.NewParser()
+ reader := strings.NewReader(strings.TrimSpace(cmd))
+ prog, err := parser.Parse(reader, "")
+ if err != nil {
+ return "", fmt.Errorf("unable to parse command - %w", err)
+ }
+
+ if envList == nil {
+ envList = make([]string, 0)
+ }
+ envList = append(os.Environ(), envList...)
+
+ stdInBuffer := strings.NewReader(input)
+ stdOutBuffer := &strings.Builder{}
+ stdErrBuffer := &strings.Builder{}
+
+ runner, err := interp.New(
+ interp.Dir(dir),
+ interp.Env(expand.ListEnviron(envList...)),
+ interp.StdIO(
+ stdInBuffer,
+ stdOutBuffer,
+ stdErrBuffer,
+ ),
+ )
+ if err != nil {
+ return "", fmt.Errorf("unable to create runner - %w", err)
+ }
+
+ err = runner.Run(ctx, prog)
+ if err != nil {
+ var exitStatus interp.ExitStatus
+ if errors.As(err, &exitStatus) {
+ return stdErrBuffer.String(), fmt.Errorf("command exited with non-zero status %w", exitStatus)
+ }
+ return stdErrBuffer.String(), fmt.Errorf("encountered an error executing command - %w", err)
+ }
+ output := stdOutBuffer.String()
+ if stderr := stdErrBuffer.String(); stderr != "" {
+ output += "\n" + stderr
+ }
+ return strings.TrimSpace(output), nil
+}
+
+func expandEnv(env map[string]string) map[string]string {
+ for k, v := range env {
+ if strings.Contains(v, "$") || strings.Contains(v, "{") {
+ env[k] = os.ExpandEnv(v)
+ }
+ }
+ return env
+}
diff --git a/external_test.go b/external_test.go
new file mode 100644
index 0000000..cb65bca
--- /dev/null
+++ b/external_test.go
@@ -0,0 +1,432 @@
+package vault_test
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/flowexec/vault"
+)
+
+// mockCommandContext creates mock commands for testing
+func mockCommandContext(
+ outputs map[string]string,
+ errors map[string]error,
+) func(ctx context.Context, cmd, input, dir string, envList []string) (string, error) {
+ return func(ctx context.Context, cmd, input, dir string, envList []string) (string, error) {
+ for _, output := range outputs {
+ return output, nil
+ }
+
+ for range errors {
+ return "", fmt.Errorf("mock error")
+ }
+
+ return "mock", nil
+ }
+}
+
+func TestNewExternalVaultProvider(t *testing.T) {
+ tests := []struct {
+ name string
+ config *vault.Config
+ wantErr bool
+ }{
+ {
+ name: "valid config",
+ config: &vault.Config{
+ ID: "test-vault",
+ Type: vault.ProviderTypeExternal,
+ External: &vault.ExternalConfig{
+ Get: vault.CommandConfig{
+ CommandTemplate: "vault kv get -format=json {{key}}",
+ },
+ Set: vault.CommandConfig{
+ CommandTemplate: "vault kv put {{key}} value={{value}}",
+ },
+ },
+ },
+ wantErr: false,
+ },
+ {
+ name: "missing external config",
+ config: &vault.Config{
+ ID: "test-vault",
+ Type: vault.ProviderTypeExternal,
+ },
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ provider, err := vault.NewExternalVaultProvider(tt.config)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("NewExternalVaultProvider() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if !tt.wantErr && provider == nil {
+ t.Error("NewExternalVaultProvider() returned nil provider")
+ }
+ if !tt.wantErr && provider.ID() != tt.config.ID {
+ t.Errorf("NewExternalVaultProvider() ID = %v, want %v", provider.ID(), tt.config.ID)
+ }
+ })
+ }
+}
+
+func TestExternalVaultProvider_GetSecret(t *testing.T) {
+ config := &vault.Config{
+ ID: "test-vault",
+ Type: vault.ProviderTypeExternal,
+ External: &vault.ExternalConfig{
+ Get: vault.CommandConfig{
+ CommandTemplate: "vault kv get -format=json {{key}}",
+ },
+ },
+ }
+
+ provider, err := vault.NewExternalVaultProvider(config)
+ if err != nil {
+ t.Fatalf("Failed to create provider: %v", err)
+ }
+
+ tests := []struct {
+ name string
+ key string
+ mockOutputs map[string]string
+ mockErrors map[string]error
+ wantSecret string
+ wantErr bool
+ errorContains string
+ }{
+ {
+ name: "successful get",
+ key: "test-key",
+ mockOutputs: map[string]string{
+ "vault kv get -format=json test-key": "secret-value",
+ },
+ wantSecret: "secret-value",
+ wantErr: false,
+ },
+ {
+ name: "command fails",
+ key: "test-key",
+ mockErrors: map[string]error{
+ "vault kv get -format=json test-key": fmt.Errorf("command failed"),
+ },
+ wantErr: true,
+ errorContains: "failed to get secret",
+ },
+ {
+ name: "invalid key",
+ key: "",
+ wantErr: true,
+ errorContains: "invalid secret key",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ testProvider := provider
+ if tt.name == "get operation not configured" {
+ testConfig := &vault.Config{
+ ID: "test-vault",
+ Type: vault.ProviderTypeExternal,
+ External: &vault.ExternalConfig{},
+ }
+ var err error
+ testProvider, err = vault.NewExternalVaultProvider(testConfig)
+ if err != nil {
+ t.Fatalf("Failed to create test provider: %v", err)
+ }
+ }
+
+ testProvider.SetExecutionFunc(mockCommandContext(tt.mockOutputs, tt.mockErrors))
+
+ secret, err := testProvider.GetSecret(tt.key)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("GetSecret() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+
+ if err != nil && tt.errorContains != "" {
+ if !strings.Contains(err.Error(), tt.errorContains) {
+ t.Errorf("GetSecret() error = %v, want error containing %v", err, tt.errorContains)
+ }
+ return
+ }
+
+ if !tt.wantErr && secret.PlainTextString() != tt.wantSecret {
+ t.Errorf("GetSecret() secret = %v, want %v", secret.PlainTextString(), tt.wantSecret)
+ }
+ })
+ }
+}
+
+func TestExternalVaultProvider_SetSecret(t *testing.T) {
+ config := &vault.Config{
+ ID: "test-vault",
+ Type: vault.ProviderTypeExternal,
+ External: &vault.ExternalConfig{
+ Set: vault.CommandConfig{
+ CommandTemplate: "vault kv put {{key}} value={{value}}",
+ },
+ },
+ }
+
+ provider, err := vault.NewExternalVaultProvider(config)
+ if err != nil {
+ t.Fatalf("Failed to create provider: %v", err)
+ }
+
+ tests := []struct {
+ name string
+ key string
+ value string
+ mockOutputs map[string]string
+ mockErrors map[string]error
+ wantErr bool
+ errorContains string
+ }{
+ {
+ name: "successful set",
+ key: "test-key",
+ value: "test-value",
+ mockOutputs: map[string]string{
+ "vault kv put test-key value=test-value": "success",
+ },
+ wantErr: false,
+ },
+ {
+ name: "command fails",
+ key: "test-key",
+ value: "test-value",
+ mockErrors: map[string]error{
+ "vault kv put test-key value=test-value": fmt.Errorf("command failed"),
+ },
+ wantErr: true,
+ errorContains: "failed to set secret",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ testProvider := provider
+ testProvider.SetExecutionFunc(mockCommandContext(tt.mockOutputs, tt.mockErrors))
+
+ secret := vault.NewSecretValue([]byte(tt.value))
+ err := testProvider.SetSecret(tt.key, secret)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("SetSecret() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+
+ if err != nil && tt.errorContains != "" {
+ if !strings.Contains(err.Error(), tt.errorContains) {
+ t.Errorf("SetSecret() error = %v, want error containing %v", err, tt.errorContains)
+ }
+ }
+ })
+ }
+}
+
+func TestExternalVaultProvider_ListSecrets(t *testing.T) {
+ config := &vault.Config{
+ ID: "test-vault",
+ Type: vault.ProviderTypeExternal,
+ External: &vault.ExternalConfig{
+ List: vault.CommandConfig{
+ CommandTemplate: "vault kv list",
+ },
+ },
+ }
+
+ provider, err := vault.NewExternalVaultProvider(config)
+ if err != nil {
+ t.Fatalf("Failed to create provider: %v", err)
+ }
+
+ tests := []struct {
+ name string
+ mockOutputs map[string]string
+ mockErrors map[string]error
+ wantSecrets []string
+ wantErr bool
+ }{
+ {
+ name: "successful list",
+ mockOutputs: map[string]string{
+ "vault kv list": "secret1\nsecret2\nsecret3",
+ },
+ wantSecrets: []string{"secret1", "secret2", "secret3"},
+ wantErr: false,
+ },
+ {
+ name: "empty list",
+ mockOutputs: map[string]string{
+ "vault kv list": "",
+ },
+ wantSecrets: []string{},
+ wantErr: false,
+ },
+ {
+ name: "command fails",
+ mockErrors: map[string]error{
+ "vault kv list": fmt.Errorf("command failed"),
+ },
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ testProvider := provider
+ testProvider.SetExecutionFunc(mockCommandContext(tt.mockOutputs, tt.mockErrors))
+
+ secrets, err := testProvider.ListSecrets()
+ if (err != nil) != tt.wantErr {
+ t.Errorf("ListSecrets() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+
+ if !tt.wantErr {
+ if len(secrets) != len(tt.wantSecrets) {
+ t.Errorf("ListSecrets() returned %d secrets, want %d", len(secrets), len(tt.wantSecrets))
+ return
+ }
+ for i, secret := range secrets {
+ if secret != tt.wantSecrets[i] {
+ t.Errorf("ListSecrets() secret[%d] = %v, want %v", i, secret, tt.wantSecrets[i])
+ }
+ }
+ }
+ })
+ }
+}
+
+func TestExternalVaultProvider_HasSecret(t *testing.T) {
+ config := &vault.Config{
+ ID: "test-vault",
+ Type: vault.ProviderTypeExternal,
+ External: &vault.ExternalConfig{
+ Exists: vault.CommandConfig{
+ CommandTemplate: "vault kv get {{key}}",
+ },
+ },
+ }
+
+ provider, err := vault.NewExternalVaultProvider(config)
+ if err != nil {
+ t.Fatalf("Failed to create provider: %v", err)
+ }
+
+ tests := []struct {
+ name string
+ key string
+ mockOutputs map[string]string
+ mockErrors map[string]error
+ wantExists bool
+ wantErr bool
+ }{
+ {
+ name: "secret exists",
+ key: "existing-key",
+ mockOutputs: map[string]string{
+ "vault kv get existing-key": "some-value",
+ },
+ wantExists: true,
+ wantErr: false,
+ },
+ {
+ name: "secret does not exist",
+ key: "nonexistent-key",
+ mockErrors: map[string]error{
+ "vault kv get nonexistent-key": fmt.Errorf("not found"),
+ },
+ wantExists: false,
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ testProvider := provider
+ testProvider.SetExecutionFunc(mockCommandContext(tt.mockOutputs, tt.mockErrors))
+
+ exists, err := testProvider.HasSecret(tt.key)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("HasSecret() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+
+ if exists != tt.wantExists {
+ t.Errorf("HasSecret() = %v, want %v", exists, tt.wantExists)
+ }
+ })
+ }
+}
+
+func TestExternalVaultProvider_Metadata(t *testing.T) {
+ tests := []struct {
+ name string
+ config *vault.ExternalConfig
+ mockOutputs map[string]string
+ mockErrors map[string]error
+ wantRawData string
+ }{
+ {
+ name: "successful metadata retrieval",
+ config: &vault.ExternalConfig{
+ Metadata: vault.CommandConfig{
+ CommandTemplate: "vault status",
+ },
+ },
+ mockOutputs: map[string]string{
+ "vault status": "vault is healthy",
+ },
+ wantRawData: "vault is healthy",
+ },
+ {
+ name: "no metadata command configured",
+ config: &vault.ExternalConfig{},
+ wantRawData: "",
+ },
+ {
+ name: "metadata command fails",
+ config: &vault.ExternalConfig{
+ Metadata: vault.CommandConfig{
+ CommandTemplate: "vault status",
+ },
+ },
+ mockErrors: map[string]error{
+ "vault status": fmt.Errorf("command failed"),
+ },
+ wantRawData: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ config := &vault.Config{
+ ID: "test-vault",
+ Type: vault.ProviderTypeExternal,
+ External: tt.config,
+ }
+
+ provider, err := vault.NewExternalVaultProvider(config)
+ if err != nil {
+ t.Fatalf("Failed to create provider: %v", err)
+ }
+
+ testProvider := provider
+ testProvider.SetExecutionFunc(mockCommandContext(tt.mockOutputs, tt.mockErrors))
+
+ metadata := testProvider.Metadata()
+ if metadata.RawData != tt.wantRawData {
+ t.Errorf("Metadata().RawData = %v, want %v", metadata.RawData, tt.wantRawData)
+ }
+ })
+ }
+}
diff --git a/go.mod b/go.mod
index 747e28b..1828694 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,8 @@
module github.com/flowexec/vault
-go 1.24
+go 1.24.0
+
+toolchain go1.24.5
require (
filippo.io/age v1.2.1
@@ -8,15 +10,18 @@ require (
)
require (
+ github.com/jahvon/expression v0.0.0-20250809212042-7737b498ffc6
github.com/zalando/go-keyring v0.2.6
gopkg.in/yaml.v3 v3.0.1
+ mvdan.cc/sh/v3 v3.12.0
)
require (
al.essio.dev/pkg/shellescape v1.5.1 // indirect
github.com/danieljoos/wincred v1.2.2 // indirect
+ github.com/expr-lang/expr v1.17.5 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
- github.com/kr/pretty v0.3.1 // indirect
golang.org/x/sys v0.33.0 // indirect
+ golang.org/x/term v0.32.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
)
diff --git a/go.sum b/go.sum
index 779edbb..c8ad35d 100644
--- a/go.sum
+++ b/go.sum
@@ -4,15 +4,24 @@ c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3I
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w=
filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o=
filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004=
-github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
+github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/expr-lang/expr v1.17.5 h1:i1WrMvcdLF249nSNlpQZN1S6NXuW9WaOfF5tPi3aw3k=
+github.com/expr-lang/expr v1.17.5/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
+github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
+github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
+github.com/jahvon/expression v0.0.0-20250809212042-7737b498ffc6 h1:V6o3MTsAe/iVxZr4bzUKWwhy0yxwTGN3WaCT5tu85go=
+github.com/jahvon/expression v0.0.0-20250809212042-7737b498ffc6/go.mod h1:2lB9n/gJ6pF8lILuOing3PaPACSTDxuIbezVMgeLxYM=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -20,12 +29,10 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
-github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
-github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
@@ -36,8 +43,12 @@ golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
+golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+mvdan.cc/sh/v3 v3.12.0 h1:ejKUR7ONP5bb+UGHGEG/k9V5+pRVIyD+LsZz7o8KHrI=
+mvdan.cc/sh/v3 v3.12.0/go.mod h1:Se6Cj17eYSn+sNooLZiEUnNNmNxg0imoYlTu4CyaGyg=
diff --git a/local.go b/local.go
index a9a4d82..80cc138 100644
--- a/local.go
+++ b/local.go
@@ -21,6 +21,7 @@ var (
type Metadata struct {
Created time.Time `json:"created"`
LastModified time.Time `json:"lastModified"`
+ RawData string `json:"data,omitempty"`
}
// validateSecurePath checks if a path is safe to use
diff --git a/vault.go b/vault.go
index 6edf241..a681699 100644
--- a/vault.go
+++ b/vault.go
@@ -46,7 +46,8 @@ func New(id string, opts ...Option) (Provider, *Config, error) {
provider, err := NewUnencryptedVault(config)
return provider, config, err
case ProviderTypeExternal:
- return nil, nil, fmt.Errorf("external vault provider not implemented yet")
+ provider, err := NewExternalVaultProvider(config)
+ return provider, config, err
}
return nil, nil, fmt.Errorf("unsupported vault type: %s", config.Type)
}
From 53c89fbd9d7d629eef9f9cb150bf91cff96d2e93 Mon Sep 17 00:00:00 2001
From: Jahvon Dockery
Date: Sun, 10 Aug 2025 21:41:44 -0400
Subject: [PATCH 4/6] test: use mocks
---
examples/main.go | 2 +-
external_test.go | 36 ++++++++++++++++++------------------
keyring_test.go | 10 +++++++++-
3 files changed, 28 insertions(+), 20 deletions(-)
diff --git a/examples/main.go b/examples/main.go
index 56dd613..89b8084 100644
--- a/examples/main.go
+++ b/examples/main.go
@@ -118,7 +118,7 @@ func listProviders() {
}
func checkEnvironmentVariables(configPath string) error {
- file, err := os.Open(configPath)
+ file, err := os.Open(filepath.Clean(configPath))
if err != nil {
return err
}
diff --git a/external_test.go b/external_test.go
index cb65bca..54a967c 100644
--- a/external_test.go
+++ b/external_test.go
@@ -9,24 +9,6 @@ import (
"github.com/flowexec/vault"
)
-// mockCommandContext creates mock commands for testing
-func mockCommandContext(
- outputs map[string]string,
- errors map[string]error,
-) func(ctx context.Context, cmd, input, dir string, envList []string) (string, error) {
- return func(ctx context.Context, cmd, input, dir string, envList []string) (string, error) {
- for _, output := range outputs {
- return output, nil
- }
-
- for range errors {
- return "", fmt.Errorf("mock error")
- }
-
- return "mock", nil
- }
-}
-
func TestNewExternalVaultProvider(t *testing.T) {
tests := []struct {
name string
@@ -430,3 +412,21 @@ func TestExternalVaultProvider_Metadata(t *testing.T) {
})
}
}
+
+// mockCommandContext creates mock commands for testing
+func mockCommandContext(
+ outputs map[string]string,
+ errors map[string]error,
+) func(ctx context.Context, cmd, input, dir string, envList []string) (string, error) {
+ return func(ctx context.Context, cmd, input, dir string, envList []string) (string, error) {
+ for _, output := range outputs {
+ return output, nil
+ }
+
+ for range errors {
+ return "", fmt.Errorf("mock error")
+ }
+
+ return "mock", nil
+ }
+}
diff --git a/keyring_test.go b/keyring_test.go
index cc400fe..6e8d1b6 100644
--- a/keyring_test.go
+++ b/keyring_test.go
@@ -4,12 +4,15 @@ import (
"errors"
"testing"
+ "github.com/zalando/go-keyring"
+
"github.com/flowexec/vault"
)
const testKeyringService = "flowexec-vault-test"
func TestKeyringVault_New(t *testing.T) {
+ keyring.MockInit()
vlt, cfg, err := vault.New("test-keyring-vault",
vault.WithProvider(vault.ProviderTypeKeyring),
vault.WithKeyringService(testKeyringService),
@@ -37,6 +40,7 @@ func TestKeyringVault_New(t *testing.T) {
}
func TestKeyringVault_SecretOperations(t *testing.T) {
+ keyring.MockInit()
vlt, _, err := vault.New("test-keyring-vault",
vault.WithProvider(vault.ProviderTypeKeyring),
vault.WithKeyringService(testKeyringService),
@@ -45,7 +49,6 @@ func TestKeyringVault_SecretOperations(t *testing.T) {
t.Fatalf("Failed to create keyring vault: %v", err)
}
defer func() {
- // Clean up any remaining secrets before closing
secrets, _ := vlt.ListSecrets()
for _, key := range secrets {
_ = vlt.DeleteSecret(key)
@@ -123,6 +126,7 @@ func TestKeyringVault_SecretOperations(t *testing.T) {
}
func TestKeyringVault_MultipleSecrets(t *testing.T) {
+ keyring.MockInit()
vlt, _, err := vault.New("test-keyring-vault-multiple",
vault.WithProvider(vault.ProviderTypeKeyring),
vault.WithKeyringService(testKeyringService),
@@ -205,6 +209,7 @@ func TestKeyringVault_MultipleSecrets(t *testing.T) {
}
func TestKeyringVault_Persistence(t *testing.T) {
+ keyring.MockInit()
testKey := "persistent-keyring-key"
testValue := "persistent-keyring-value"
@@ -258,6 +263,7 @@ func TestKeyringVault_Persistence(t *testing.T) {
}
func TestKeyringVault_Metadata(t *testing.T) {
+ keyring.MockInit()
vlt, _, err := vault.New("test-keyring-metadata",
vault.WithProvider(vault.ProviderTypeKeyring),
vault.WithKeyringService(testKeyringService),
@@ -293,6 +299,7 @@ func TestKeyringVault_Metadata(t *testing.T) {
}
func TestKeyringVault_InvalidKeyValidation(t *testing.T) {
+ keyring.MockInit()
vlt, _, err := vault.New("test-keyring-validation",
vault.WithProvider(vault.ProviderTypeKeyring),
vault.WithKeyringService(testKeyringService),
@@ -325,6 +332,7 @@ func TestKeyringVault_InvalidKeyValidation(t *testing.T) {
}
func TestKeyringVault_SortedOutput(t *testing.T) {
+ keyring.MockInit()
vlt, _, err := vault.New("test-keyring-sorted",
vault.WithProvider(vault.ProviderTypeKeyring),
vault.WithKeyringService(testKeyringService),
From b1a2818abde9201f52f295303b237502a9df150b Mon Sep 17 00:00:00 2001
From: Jahvon Dockery
Date: Sun, 10 Aug 2025 21:42:41 -0400
Subject: [PATCH 5/6] chore: add claude to gitignore
---
.claude/settings.local.json | 12 ------------
.gitignore | 1 +
2 files changed, 1 insertion(+), 12 deletions(-)
delete mode 100644 .claude/settings.local.json
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
deleted file mode 100644
index 90a0d98..0000000
--- a/.claude/settings.local.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "permissions": {
- "allow": [
- "Bash(go test:*)",
- "Bash(go build:*)",
- "mcp__flow__execute",
- "mcp__flow__list_executables",
- "Bash(go fmt:*)"
- ],
- "deny": []
- }
-}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 0107da0..52d3f3b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
*.age
*.txt
playground/*
+.claude/
\ No newline at end of file
From 31c33cae9b54df044b1e80777c6d291ae30cbe06 Mon Sep 17 00:00:00 2001
From: Jahvon Dockery
Date: Sun, 10 Aug 2025 21:47:44 -0400
Subject: [PATCH 6/6] chore: bump deps
---
go.mod | 13 ++++---------
go.sum | 16 ++++++++--------
2 files changed, 12 insertions(+), 17 deletions(-)
diff --git a/go.mod b/go.mod
index 1828694..2715aa9 100644
--- a/go.mod
+++ b/go.mod
@@ -2,16 +2,11 @@ module github.com/flowexec/vault
go 1.24.0
-toolchain go1.24.5
-
require (
filippo.io/age v1.2.1
- golang.org/x/crypto v0.39.0
-)
-
-require (
- github.com/jahvon/expression v0.0.0-20250809212042-7737b498ffc6
+ github.com/jahvon/expression v0.1.1
github.com/zalando/go-keyring v0.2.6
+ golang.org/x/crypto v0.41.0
gopkg.in/yaml.v3 v3.0.1
mvdan.cc/sh/v3 v3.12.0
)
@@ -21,7 +16,7 @@ require (
github.com/danieljoos/wincred v1.2.2 // indirect
github.com/expr-lang/expr v1.17.5 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
- golang.org/x/sys v0.33.0 // indirect
- golang.org/x/term v0.32.0 // indirect
+ golang.org/x/sys v0.35.0 // indirect
+ golang.org/x/term v0.34.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
)
diff --git a/go.sum b/go.sum
index c8ad35d..1d7c6f1 100644
--- a/go.sum
+++ b/go.sum
@@ -20,8 +20,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
-github.com/jahvon/expression v0.0.0-20250809212042-7737b498ffc6 h1:V6o3MTsAe/iVxZr4bzUKWwhy0yxwTGN3WaCT5tu85go=
-github.com/jahvon/expression v0.0.0-20250809212042-7737b498ffc6/go.mod h1:2lB9n/gJ6pF8lILuOing3PaPACSTDxuIbezVMgeLxYM=
+github.com/jahvon/expression v0.1.1 h1:Impbzo6to4gLYAL/W11KYhAwap3grp14yShh3jJiKNk=
+github.com/jahvon/expression v0.1.1/go.mod h1:4HJB2k+epW5vFeptF6ILlXbFRQ+CuCyCSO4QdnGT3AE=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -39,12 +39,12 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s=
github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
-golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
-golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
-golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
-golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
-golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
-golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
+golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
+golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
+golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
+golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
+golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=