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