1// Package domainsecrets implements the DomainSecretStore adapter using filesystem-based env files.
2package domainsecrets
3
4import (
5 "bufio"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10
11 "github.com/bnema/zerowrap"
12)
13
14// FileStore implements the DomainSecretStore interface using filesystem-based env files.
15// This adapter is responsible only for file I/O operations; domain validation
16// should be performed by the use case layer before calling these methods.
17type FileStore struct {
18 envDir string
19 log zerowrap.Logger
20}
21
22// NewFileStore creates a new file-based domain secret store.
23func NewFileStore(envDir string, log zerowrap.Logger) (*FileStore, error) {
24 // Ensure env directory exists
25 if err := os.MkdirAll(envDir, 0700); err != nil {
26 return nil, fmt.Errorf("failed to create env directory %s: %w", envDir, err)
27 }
28
29 log.Debug().
30 Str(zerowrap.FieldLayer, "adapter").
31 Str(zerowrap.FieldAdapter, "domainsecrets").
32 Str("env_dir", envDir).
33 Msg("domain secret store initialized")
34
35 return &FileStore{
36 envDir: envDir,
37 log: log,
38 }, nil
39}
40
41// ListKeys returns the list of secret keys for a domain (not values).
42func (s *FileStore) ListKeys(domain string) ([]string, error) {
43 envFile := s.getEnvFilePath(domain)
44
45 file, err := os.Open(envFile)
46 if err != nil {
47 if os.IsNotExist(err) {
48 return []string{}, nil
49 }
50 return nil, fmt.Errorf("failed to open env file: %w", err)
51 }
52 defer file.Close()
53
54 var keys []string
55 scanner := bufio.NewScanner(file)
56 for scanner.Scan() {
57 line := strings.TrimSpace(scanner.Text())
58 // Skip empty lines and comments
59 if line == "" || strings.HasPrefix(line, "#") {
60 continue
61 }
62 // Extract key from KEY=value
63 if idx := strings.Index(line, "="); idx > 0 {
64 keys = append(keys, line[:idx])
65 }
66 }
67
68 if err := scanner.Err(); err != nil {
69 return nil, fmt.Errorf("failed to read env file: %w", err)
70 }
71
72 return keys, nil
73}
74
75// GetAll returns all secrets for a domain as a key-value map.
76func (s *FileStore) GetAll(domain string) (map[string]string, error) {
77 envFile := s.getEnvFilePath(domain)
78
79 file, err := os.Open(envFile)
80 if err != nil {
81 if os.IsNotExist(err) {
82 return map[string]string{}, nil
83 }
84 return nil, fmt.Errorf("failed to open env file: %w", err)
85 }
86 defer file.Close()
87
88 secrets := make(map[string]string)
89 scanner := bufio.NewScanner(file)
90 for scanner.Scan() {
91 line := strings.TrimSpace(scanner.Text())
92 // Skip empty lines and comments
93 if line == "" || strings.HasPrefix(line, "#") {
94 continue
95 }
96 // Parse KEY=value
97 if idx := strings.Index(line, "="); idx > 0 {
98 key := line[:idx]
99 value := line[idx+1:]
100 secrets[key] = value
101 }
102 }
103
104 if err := scanner.Err(); err != nil {
105 return nil, fmt.Errorf("failed to read env file: %w", err)
106 }
107
108 return secrets, nil
109}
110
111// Set sets or updates multiple secrets for a domain, merging with existing.
112func (s *FileStore) Set(domain string, secrets map[string]string) error {
113 // Ensure env directory exists
114 if err := os.MkdirAll(s.envDir, 0700); err != nil {
115 return fmt.Errorf("failed to create env directory: %w", err)
116 }
117
118 // Read existing secrets
119 existing, err := s.GetAll(domain)
120 if err != nil {
121 return err
122 }
123
124 // Merge new secrets with existing
125 for key, value := range secrets {
126 existing[key] = value
127 }
128
129 // Write back atomically
130 return s.writeSecretsAtomic(domain, existing)
131}
132
133// Delete removes a specific secret key from a domain.
134func (s *FileStore) Delete(domain, key string) error {
135 // Read existing secrets
136 existing, err := s.GetAll(domain)
137 if err != nil {
138 return err
139 }
140
141 // Remove the key
142 delete(existing, key)
143
144 // Write back atomically
145 return s.writeSecretsAtomic(domain, existing)
146}
147
148// writeSecretsAtomic writes all secrets to the domain's env file atomically.
149// It writes to a temporary file first, syncs it, then renames to the final path.
150func (s *FileStore) writeSecretsAtomic(domain string, secrets map[string]string) error {
151 envFile := s.getEnvFilePath(domain)
152 tmpFile := envFile + ".tmp"
153
154 file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
155 if err != nil {
156 return fmt.Errorf("failed to create temp env file: %w", err)
157 }
158
159 // Write header comment
160 if _, err := fmt.Fprintf(file, "# Environment variables for %s\n", domain); err != nil {
161 file.Close()
162 os.Remove(tmpFile)
163 return fmt.Errorf("failed to write header: %w", err)
164 }
165 if _, err := fmt.Fprintf(file, "# Managed by Gordon admin API\n\n"); err != nil {
166 file.Close()
167 os.Remove(tmpFile)
168 return fmt.Errorf("failed to write header: %w", err)
169 }
170
171 // Write each secret
172 for key, value := range secrets {
173 if _, err := fmt.Fprintf(file, "%s=%s\n", key, value); err != nil {
174 file.Close()
175 os.Remove(tmpFile)
176 return fmt.Errorf("failed to write secret %s: %w", key, err)
177 }
178 }
179
180 // Sync to ensure data is on disk before rename
181 if err := file.Sync(); err != nil {
182 file.Close()
183 os.Remove(tmpFile)
184 return fmt.Errorf("failed to sync env file: %w", err)
185 }
186
187 if err := file.Close(); err != nil {
188 os.Remove(tmpFile)
189 return fmt.Errorf("failed to close env file: %w", err)
190 }
191
192 // Atomic rename
193 if err := os.Rename(tmpFile, envFile); err != nil {
194 os.Remove(tmpFile)
195 return fmt.Errorf("failed to rename env file: %w", err)
196 }
197
198 return nil
199}
200
201// getEnvFilePath converts a domain to its env file path.
202// This must match the naming convention in envloader.FileLoader.getEnvFilePath.
203func (s *FileStore) getEnvFilePath(domain string) string {
204 // Create domain-safe filename (replace dots and other chars with underscores)
205 safeDomain := strings.ReplaceAll(domain, ".", "_")
206 safeDomain = strings.ReplaceAll(safeDomain, ":", "_")
207 safeDomain = strings.ReplaceAll(safeDomain, "/", "_")
208 return filepath.Join(s.envDir, safeDomain+".env")
209}