feat: load explicit configuration references and remove implicit env overlays
This commit is contained in:
210
config/references.go
Normal file
210
config/references.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// WithLookupEnv supplies an explicit-reference lookup for tests or an embedding
|
||||
// host. Ordinary projects need no loader options. The function must support
|
||||
// concurrent loads; each load caches each referenced name independently.
|
||||
func WithLookupEnv(lookup func(string) (string, bool)) LoaderOption {
|
||||
return func(l *Loader) error {
|
||||
if l == nil || l.sealed || lookup == nil {
|
||||
return errors.New("config: expected a non-nil environment lookup on a new loader")
|
||||
}
|
||||
l.lookup = lookup
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// resolvedText is textual input bound according to the target scalar type. It
|
||||
// never becomes an object, collection or null by interpreting environment data.
|
||||
type resolvedText string
|
||||
|
||||
const maxExpandedBytes = 4 << 20
|
||||
|
||||
type environmentValue struct {
|
||||
value string
|
||||
present bool
|
||||
}
|
||||
type referenceResolver struct {
|
||||
lookup func(string) (string, bool)
|
||||
cache map[string]environmentValue
|
||||
doc Document
|
||||
nodes, bytes, cacheBytes int
|
||||
}
|
||||
|
||||
func resolveDocument(doc Document, lookup func(string) (string, bool)) (Document, error) {
|
||||
r := referenceResolver{lookup: lookup, cache: map[string]environmentValue{}, doc: Document{Positions: doc.Positions, environments: map[string][]string{}}}
|
||||
v, err := r.walk(doc.Value, "", 0)
|
||||
if err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
r.doc.Value = v
|
||||
return r.doc, nil
|
||||
}
|
||||
func (r *referenceResolver) walk(v any, path string, depth int) (any, error) {
|
||||
r.nodes++
|
||||
if depth > maxConfigDepth || r.nodes > maxConfigNodes {
|
||||
return nil, r.doc.diagnostic(path, "configuration nesting or node limit exceeded", nil)
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return r.text(x, path, false)
|
||||
case Text:
|
||||
return r.text(string(x), path, true)
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(x))
|
||||
for _, key := range sortedKeys(x) {
|
||||
value, err := r.walk(x[key], joinField(path, key), depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[key] = value
|
||||
}
|
||||
return out, nil
|
||||
case []any:
|
||||
out := make([]any, len(x))
|
||||
for i, value := range x {
|
||||
var err error
|
||||
out[i], err = r.walk(value, fmt.Sprintf("%s[%d]", path, i), depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
default:
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
func (r *referenceResolver) fail(path, name, reason string) error {
|
||||
d := r.doc.diagnostic(path, reason, nil)
|
||||
d.Env = name
|
||||
return d
|
||||
}
|
||||
func validEnvironmentName(name string) bool {
|
||||
if len(name) == 0 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(name); i++ {
|
||||
c := name[i]
|
||||
if c == '_' || c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || i > 0 && c >= '0' && c <= '9' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func (r *referenceResolver) lookupValue(name, path string) (environmentValue, error) {
|
||||
if v, ok := r.cache[name]; ok {
|
||||
return v, nil
|
||||
}
|
||||
value, present := r.lookup(name)
|
||||
if len(value) > maxConfigBytes || r.cacheBytes+len(value) > maxExpandedBytes {
|
||||
return environmentValue{}, r.fail(path, name, "environment value exceeds configuration size limit")
|
||||
}
|
||||
if !utf8.ValidString(value) {
|
||||
return environmentValue{}, r.fail(path, name, "environment value is not valid UTF-8")
|
||||
}
|
||||
|
||||
r.cacheBytes += len(value)
|
||||
v := environmentValue{value, present}
|
||||
r.cache[name] = v
|
||||
return v, nil
|
||||
}
|
||||
func (r *referenceResolver) text(raw, path string, xmlText bool) (any, error) {
|
||||
var out strings.Builder
|
||||
var names []string
|
||||
seen := map[string]bool{}
|
||||
if !strings.Contains(raw, "$") {
|
||||
if len(raw) > maxConfigBytes || r.bytes+len(raw) > maxExpandedBytes {
|
||||
return nil, r.fail(path, "", "expanded configuration exceeds size limit")
|
||||
}
|
||||
r.bytes += len(raw)
|
||||
if xmlText {
|
||||
return Text(raw), nil
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
appendText := func(s string) error {
|
||||
if out.Len()+len(s) > maxConfigBytes || r.bytes+len(s) > maxExpandedBytes {
|
||||
return r.fail(path, strings.Join(names, ", "), "expanded configuration exceeds size limit")
|
||||
}
|
||||
out.WriteString(s)
|
||||
r.bytes += len(s)
|
||||
return nil
|
||||
}
|
||||
// Scan only the file's decoded scalar. Inserted environment values are never
|
||||
// scanned again, including dollars, quotes, separators or newline characters.
|
||||
for i := 0; i < len(raw); {
|
||||
j := strings.IndexByte(raw[i:], '$')
|
||||
if j < 0 {
|
||||
if err := appendText(raw[i:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
break
|
||||
}
|
||||
j += i
|
||||
if err := appendText(raw[i:j]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
case j+1 < len(raw) && raw[j+1] == '$':
|
||||
if err := appendText("$"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
i = j + 2
|
||||
case j+1 < len(raw) && raw[j+1] == '{':
|
||||
end := strings.IndexByte(raw[j+2:], '}')
|
||||
if end < 0 {
|
||||
return nil, r.fail(path, "", "unclosed environment reference; use ${NAME}, ${NAME:-fallback} or $${...} for a literal")
|
||||
}
|
||||
end += j + 2
|
||||
expression := raw[j+2 : end]
|
||||
name, fallback, hasDefault := strings.Cut(expression, ":-")
|
||||
if !validEnvironmentName(name) || strings.Contains(fallback, "${") {
|
||||
return nil, r.fail(path, "", "invalid environment reference; use ${NAME} or ${NAME:-fallback}; nested references are not supported")
|
||||
}
|
||||
r.nodes++
|
||||
if r.nodes > maxConfigNodes {
|
||||
return nil, r.fail(path, name, "environment reference count exceeds configuration limit")
|
||||
}
|
||||
if !seen[name] {
|
||||
names = append(names, name)
|
||||
seen[name] = true
|
||||
}
|
||||
|
||||
value, err := r.lookupValue(name, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
selected := value.value
|
||||
if !value.present || selected == "" {
|
||||
if !hasDefault {
|
||||
return nil, r.fail(path, name, "required environment variable is unset or empty")
|
||||
}
|
||||
selected = fallback
|
||||
}
|
||||
if err := appendText(selected); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
i = end + 1
|
||||
default:
|
||||
if err := appendText("$"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
i = j + 1
|
||||
}
|
||||
}
|
||||
if len(names) > 0 {
|
||||
r.doc.environments[path] = names
|
||||
return resolvedText(out.String()), nil
|
||||
}
|
||||
if xmlText {
|
||||
return Text(out.String()), nil
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
Reference in New Issue
Block a user