configvalue.go

  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}