1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
4
5// Package configvalue resolves literal, environment, and command-backed configuration values.
6package configvalue
7
8import (
9 "context"
10 "fmt"
11 "io"
12 "os"
13 "os/exec"
14 "regexp"
15 "runtime"
16 "strings"
17 "sync"
18 "time"
19)
20
21const maxCommandStdoutBytes = 64 * 1024
22
23var (
24 envNameRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
25 envNamePrefixRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*`)
26)
27
28type partKind int
29
30const (
31 partLiteral partKind = iota
32 partEnv
33)
34
35type templatePart struct {
36 kind partKind
37 value string
38}
39
40type result struct {
41 value string
42 ok bool
43}
44
45// Resolver resolves config-values.RESOLUTION.1 configuration strings.
46type Resolver struct {
47 CommandTimeout time.Duration
48
49 shellCommand func(string) (string, []string)
50 mu sync.Mutex
51 cache map[string]result
52}
53
54// NewResolver returns a resolver with the default command timeout.
55func NewResolver() *Resolver {
56 return &Resolver{
57 CommandTimeout: 10 * time.Second,
58 shellCommand: platformShellCommand,
59 cache: make(map[string]result),
60 }
61}
62
63// Resolve resolves a literal, environment-interpolated, or command-backed value.
64func (r *Resolver) Resolve(ctx context.Context, config string) (string, bool) {
65 if strings.HasPrefix(config, "!") {
66 return r.resolveCommand(ctx, config)
67 }
68
69 return resolveTemplate(parseTemplate(config))
70}
71
72// ResolveOrThrow resolves a configuration value or returns a setting-specific error.
73func (r *Resolver) ResolveOrThrow(ctx context.Context, config, description string) (string, error) {
74 value, ok := r.Resolve(ctx, config)
75 if ok {
76 return value, nil
77 }
78
79 if strings.HasPrefix(config, "!") {
80 return "", fmt.Errorf("failed to resolve %s from shell command", description)
81 }
82
83 missing := MissingEnvVarNames(config)
84 switch len(missing) {
85 case 0:
86 return "", fmt.Errorf("failed to resolve %s", description)
87 case 1:
88 return "", fmt.Errorf("failed to resolve %s from environment variable: %s", description, missing[0])
89 default:
90 return "", fmt.Errorf(
91 "failed to resolve %s from environment variables: %s",
92 description,
93 strings.Join(missing, ", "),
94 )
95 }
96}
97
98// ResolveWithExplicitEnvOverride resolves an explicit environment override before a config value.
99func ResolveWithExplicitEnvOverride(
100 ctx context.Context,
101 resolver *Resolver,
102 explicitEnvName, configValue, description string,
103) (string, error) {
104 if value := os.Getenv(explicitEnvName); value != "" {
105 return value, nil
106 }
107
108 return resolver.ResolveOrThrow(ctx, configValue, description)
109}
110
111func (r *Resolver) resolveCommand(ctx context.Context, commandConfig string) (string, bool) {
112 r.mu.Lock()
113 cached, cachedOK := r.cache[commandConfig]
114 r.mu.Unlock()
115 if cachedOK {
116 return cached.value, cached.ok
117 }
118
119 value, ok := r.executeCommand(ctx, strings.TrimPrefix(commandConfig, "!"))
120
121 r.mu.Lock()
122 r.cache[commandConfig] = result{value: value, ok: ok}
123 r.mu.Unlock()
124
125 return value, ok
126}
127
128func (r *Resolver) executeCommand(ctx context.Context, command string) (string, bool) {
129 timeout := r.CommandTimeout
130 if timeout <= 0 {
131 timeout = 10 * time.Second
132 }
133
134 ctx, cancel := context.WithTimeout(ctx, timeout)
135 defer cancel()
136
137 name, args := r.shellCommand(command)
138 cmd := exec.CommandContext(ctx, name, args...)
139 cmd.Stdin = nil
140 cmd.Stderr = io.Discard
141
142 output, err := commandOutput(cmd)
143 if err != nil {
144 return "", false
145 }
146
147 value := strings.TrimSpace(string(output))
148 if value == "" {
149 return "", false
150 }
151
152 return value, true
153}
154
155func commandOutput(cmd *exec.Cmd) ([]byte, error) {
156 stdout, err := cmd.StdoutPipe()
157 if err != nil {
158 return nil, fmt.Errorf("capture command stdout: %w", err)
159 }
160
161 if err := cmd.Start(); err != nil {
162 return nil, fmt.Errorf("start command: %w", err)
163 }
164
165 output, err := io.ReadAll(io.LimitReader(stdout, maxCommandStdoutBytes+1))
166 oversized := len(output) > maxCommandStdoutBytes
167 if err != nil || oversized {
168 if cmd.Process != nil {
169 _ = cmd.Process.Kill()
170 }
171 _ = cmd.Wait()
172 if oversized {
173 return nil, fmt.Errorf("command stdout exceeds limit")
174 }
175
176 return nil, fmt.Errorf("read command stdout: %w", err)
177 }
178
179 if err := cmd.Wait(); err != nil {
180 return nil, fmt.Errorf("wait for command: %w", err)
181 }
182
183 return output, nil
184}
185
186func platformShellCommand(command string) (string, []string) {
187 if runtime.GOOS == "windows" {
188 return "cmd.exe", []string{"/d", "/s", "/c", command}
189 }
190
191 return "sh", []string{"-c", command}
192}
193
194func parseTemplate(config string) []templatePart {
195 var parts []templatePart
196
197 index := 0
198 for index < len(config) {
199 literal, part, hasPart, next := nextTemplatePart(config, index)
200 appendTemplateLiteral(&parts, literal)
201 if hasPart {
202 parts = append(parts, part)
203 }
204 index = next
205 }
206
207 return parts
208}
209
210func appendTemplateLiteral(parts *[]templatePart, value string) {
211 if value == "" {
212 return
213 }
214
215 last := len(*parts) - 1
216 if last >= 0 && (*parts)[last].kind == partLiteral {
217 (*parts)[last].value += value
218 return
219 }
220
221 *parts = append(*parts, templatePart{kind: partLiteral, value: value})
222}
223
224func nextTemplatePart(config string, index int) (string, templatePart, bool, int) {
225 relativeDollar := strings.IndexByte(config[index:], '$')
226 if relativeDollar < 0 {
227 return config[index:], templatePart{}, false, len(config)
228 }
229
230 dollar := index + relativeDollar
231 literal := config[index:dollar]
232 if dollar+1 >= len(config) {
233 return literal + "$", templatePart{}, false, dollar + 1
234 }
235
236 next := config[dollar+1]
237 if next == '$' || next == '!' {
238 return literal + string(next), templatePart{}, false, dollar + 2
239 }
240 if next == '{' {
241 return nextBracedTemplatePart(config, dollar, literal)
242 }
243
244 name := envNamePrefixRE.FindString(config[dollar+1:])
245 if name == "" {
246 return literal + "$", templatePart{}, false, dollar + 1
247 }
248
249 return literal, templatePart{kind: partEnv, value: name}, true, dollar + 1 + len(name)
250}
251
252func nextBracedTemplatePart(config string, dollar int, literal string) (string, templatePart, bool, int) {
253 end := strings.IndexByte(config[dollar+2:], '}')
254 if end < 0 {
255 return literal + "$", templatePart{}, false, dollar + 1
256 }
257
258 endIndex := dollar + 2 + end
259 name := config[dollar+2 : endIndex]
260 if !envNameRE.MatchString(name) {
261 return literal + config[dollar:endIndex+1], templatePart{}, false, endIndex + 1
262 }
263
264 return literal, templatePart{kind: partEnv, value: name}, true, endIndex + 1
265}
266
267func resolveTemplate(parts []templatePart) (string, bool) {
268 var resolved strings.Builder
269
270 for _, part := range parts {
271 switch part.kind {
272 case partLiteral:
273 resolved.WriteString(part.value)
274 case partEnv:
275 value := os.Getenv(part.value)
276 if value == "" {
277 return "", false
278 }
279 resolved.WriteString(value)
280 }
281 }
282
283 return resolved.String(), true
284}
285
286// EnvVarNames returns referenced environment variable names in first-seen order.
287func EnvVarNames(config string) []string {
288 if strings.HasPrefix(config, "!") {
289 return nil
290 }
291
292 seen := make(map[string]struct{})
293 var names []string
294
295 for _, part := range parseTemplate(config) {
296 if part.kind != partEnv {
297 continue
298 }
299
300 if _, ok := seen[part.value]; ok {
301 continue
302 }
303
304 seen[part.value] = struct{}{}
305 names = append(names, part.value)
306 }
307
308 return names
309}
310
311// MissingEnvVarNames returns referenced environment variable names that do not resolve.
312func MissingEnvVarNames(config string) []string {
313 var missing []string
314
315 for _, name := range EnvVarNames(config) {
316 if os.Getenv(name) == "" {
317 missing = append(missing, name)
318 }
319 }
320
321 return missing
322}