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