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
 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}