211 lines
5.8 KiB
Go
211 lines
5.8 KiB
Go
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
|
|
}
|