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("failed to resolve %s from environment variables: %s", description, strings.Join(missing, ", "))
87 }
88}
89
90// ResolveWithExplicitEnvOverride resolves an explicit environment override before a config value.
91func ResolveWithExplicitEnvOverride(ctx context.Context, resolver *Resolver, explicitEnvName, configValue, description string) (string, error) {
92 if value := os.Getenv(explicitEnvName); value != "" {
93 return value, nil
94 }
95
96 return resolver.ResolveOrThrow(ctx, configValue, description)
97}
98
99func (r *Resolver) resolveCommand(ctx context.Context, commandConfig string) (string, bool) {
100 r.mu.Lock()
101 cached, cachedOK := r.cache[commandConfig]
102 r.mu.Unlock()
103 if cachedOK {
104 return cached.value, cached.ok
105 }
106
107 value, ok := r.executeCommand(ctx, strings.TrimPrefix(commandConfig, "!"))
108
109 r.mu.Lock()
110 r.cache[commandConfig] = result{value: value, ok: ok}
111 r.mu.Unlock()
112
113 return value, ok
114}
115
116func (r *Resolver) executeCommand(ctx context.Context, command string) (string, bool) {
117 timeout := r.CommandTimeout
118 if timeout <= 0 {
119 timeout = 10 * time.Second
120 }
121
122 ctx, cancel := context.WithTimeout(ctx, timeout)
123 defer cancel()
124
125 name, args := shellCommand(command)
126 cmd := exec.CommandContext(ctx, name, args...)
127 cmd.Stdin = nil
128 cmd.Stderr = io.Discard
129
130 output, err := cmd.Output()
131 if err != nil {
132 return "", false
133 }
134
135 value := strings.TrimSpace(string(output))
136 if value == "" {
137 return "", false
138 }
139
140 return value, true
141}
142
143func shellCommand(command string) (string, []string) {
144 if runtime.GOOS == "windows" {
145 return "cmd.exe", []string{"/d", "/s", "/c", command}
146 }
147
148 return "sh", []string{"-c", command}
149}
150
151func parseTemplate(config string) []templatePart {
152 var parts []templatePart
153
154 appendLiteral := func(value string) {
155 if value == "" {
156 return
157 }
158
159 last := len(parts) - 1
160 if last >= 0 && parts[last].kind == partLiteral {
161 parts[last].value += value
162 return
163 }
164
165 parts = append(parts, templatePart{kind: partLiteral, value: value})
166 }
167
168 index := 0
169 for index < len(config) {
170 relativeDollar := strings.IndexByte(config[index:], '$')
171 if relativeDollar < 0 {
172 appendLiteral(config[index:])
173 break
174 }
175
176 dollar := index + relativeDollar
177 appendLiteral(config[index:dollar])
178
179 if dollar+1 >= len(config) {
180 appendLiteral("$")
181 index = dollar + 1
182 continue
183 }
184
185 next := config[dollar+1]
186 if next == '$' || next == '!' {
187 appendLiteral(string(next))
188 index = dollar + 2
189 continue
190 }
191
192 if next == '{' {
193 end := strings.IndexByte(config[dollar+2:], '}')
194 if end < 0 {
195 appendLiteral("$")
196 index = dollar + 1
197 continue
198 }
199
200 endIndex := dollar + 2 + end
201 name := config[dollar+2 : endIndex]
202 if envNameRE.MatchString(name) {
203 parts = append(parts, templatePart{kind: partEnv, value: name})
204 } else {
205 appendLiteral(config[dollar : endIndex+1])
206 }
207
208 index = endIndex + 1
209 continue
210 }
211
212 name := envNamePrefixRE.FindString(config[dollar+1:])
213 if name != "" {
214 parts = append(parts, templatePart{kind: partEnv, value: name})
215 index = dollar + 1 + len(name)
216 continue
217 }
218
219 appendLiteral("$")
220 index = dollar + 1
221 }
222
223 return parts
224}
225
226func resolveTemplate(parts []templatePart) (string, bool) {
227 var resolved strings.Builder
228
229 for _, part := range parts {
230 switch part.kind {
231 case partLiteral:
232 resolved.WriteString(part.value)
233 case partEnv:
234 value := os.Getenv(part.value)
235 if value == "" {
236 return "", false
237 }
238 resolved.WriteString(value)
239 }
240 }
241
242 return resolved.String(), true
243}
244
245// EnvVarNames returns referenced environment variable names in first-seen order.
246func EnvVarNames(config string) []string {
247 if strings.HasPrefix(config, "!") {
248 return nil
249 }
250
251 seen := make(map[string]struct{})
252 var names []string
253
254 for _, part := range parseTemplate(config) {
255 if part.kind != partEnv {
256 continue
257 }
258
259 if _, ok := seen[part.value]; ok {
260 continue
261 }
262
263 seen[part.value] = struct{}{}
264 names = append(names, part.value)
265 }
266
267 return names
268}
269
270// MissingEnvVarNames returns referenced environment variable names that do not resolve.
271func MissingEnvVarNames(config string) []string {
272 var missing []string
273
274 for _, name := range EnvVarNames(config) {
275 if os.Getenv(name) == "" {
276 missing = append(missing, name)
277 }
278 }
279
280 return missing
281}