Add config loader with strict stdlib-only TOML subset parser
Parse keyproxy.toml (listen address, [auth] token_ref, and the mpk-<name>
ref map) using a deliberate TOML subset implemented with the standard
library only. The surface is strict: unknown keys, malformed refs, and
backend-specific misconfigurations (e.g. env refs setting key, file refs
missing key) fail loudly at startup. The bearer-token ref must resolve
through the file backend. Parse errors carry line numbers, never line
contents.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Doc is the parsed representation of keyproxy.toml: nested tables are
|
||||
// Doc values; leaves are string, int64 or bool. This is a deliberate
|
||||
// stdlib-only TOML subset (tables with quoted keys, basic strings with
|
||||
// escapes, integers, booleans, comments). Anything richer is a parse
|
||||
// error so a misconfigured file fails loudly instead of silently.
|
||||
type Doc map[string]any
|
||||
|
||||
// Table returns the nested table at the key path, or an empty doc.
|
||||
func (d Doc) Table(keys ...string) Doc {
|
||||
cur := any(d)
|
||||
for _, k := range keys {
|
||||
m, ok := cur.(Doc)
|
||||
if !ok {
|
||||
return Doc{}
|
||||
}
|
||||
cur = m[k]
|
||||
}
|
||||
if m, ok := cur.(Doc); ok {
|
||||
return m
|
||||
}
|
||||
return Doc{}
|
||||
}
|
||||
|
||||
// Str returns the string leaf at key.
|
||||
func (d Doc) Str(key string) (string, bool) {
|
||||
v, ok := d[key].(string)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// Int returns the int64 leaf at key.
|
||||
func (d Doc) Int(key string) (int64, bool) {
|
||||
v, ok := d[key].(int64)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// Bool returns the bool leaf at key.
|
||||
func (d Doc) Bool(key string) (bool, bool) {
|
||||
v, ok := d[key].(bool)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// Keys lists the doc's keys, sorted.
|
||||
func (d Doc) Keys() []string {
|
||||
out := make([]string, 0, len(d))
|
||||
for k := range d {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// parseTOML parses the TOML subset into a Doc. Errors carry line
|
||||
// numbers, never line contents (a config file may sit next to secrets
|
||||
// someone pasted in by mistake).
|
||||
func parseTOML(data []byte) (Doc, error) {
|
||||
root := Doc{}
|
||||
cur := root
|
||||
for i, raw := range strings.Split(string(data), "\n") {
|
||||
lineno := i + 1
|
||||
line := stripComment(raw)
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "[") {
|
||||
if !strings.HasSuffix(line, "]") {
|
||||
return nil, fmt.Errorf("line %d: malformed table header", lineno)
|
||||
}
|
||||
path, err := splitKeyPath(strings.TrimSpace(line[1 : len(line)-1]))
|
||||
if err != nil || len(path) == 0 {
|
||||
return nil, fmt.Errorf("line %d: malformed table header", lineno)
|
||||
}
|
||||
cur = root
|
||||
for _, seg := range path {
|
||||
next, ok := cur[seg].(Doc)
|
||||
if !ok {
|
||||
if _, exists := cur[seg]; exists {
|
||||
return nil, fmt.Errorf("line %d: table conflicts with value key %q", lineno, seg)
|
||||
}
|
||||
next = Doc{}
|
||||
cur[seg] = next
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
continue
|
||||
}
|
||||
eq := strings.IndexByte(line, '=')
|
||||
if eq < 1 {
|
||||
return nil, fmt.Errorf("line %d: expected key = value", lineno)
|
||||
}
|
||||
key := strings.TrimSpace(line[:eq])
|
||||
key = strings.Trim(key, `"'`)
|
||||
if key == "" || strings.ContainsAny(key, `".[]`) {
|
||||
return nil, fmt.Errorf("line %d: malformed key", lineno)
|
||||
}
|
||||
value := strings.TrimSpace(line[eq+1:])
|
||||
parsed, err := parseValue(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("line %d: %v", lineno, err)
|
||||
}
|
||||
if _, dup := cur[key]; dup {
|
||||
return nil, fmt.Errorf("line %d: duplicate key %q", lineno, key)
|
||||
}
|
||||
cur[key] = parsed
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
// splitKeyPath splits a.b."c.d" into [a b c.d]; each segment is bare or
|
||||
// double-quoted.
|
||||
func splitKeyPath(s string) ([]string, error) {
|
||||
var segs []string
|
||||
for i := 0; i < len(s); {
|
||||
for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
|
||||
i++
|
||||
}
|
||||
if i >= len(s) {
|
||||
break
|
||||
}
|
||||
switch s[i] {
|
||||
case '"':
|
||||
j := i + 1
|
||||
for j < len(s) && s[j] != '"' {
|
||||
j++
|
||||
}
|
||||
if j >= len(s) {
|
||||
return nil, fmt.Errorf("unterminated quoted key")
|
||||
}
|
||||
segs = append(segs, s[i+1:j])
|
||||
i = j + 1
|
||||
default:
|
||||
j := i
|
||||
for j < len(s) && s[j] != '.' {
|
||||
j++
|
||||
}
|
||||
seg := strings.TrimSpace(s[i:j])
|
||||
if seg == "" {
|
||||
return nil, fmt.Errorf("empty key segment")
|
||||
}
|
||||
segs = append(segs, seg)
|
||||
i = j
|
||||
}
|
||||
for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
|
||||
i++
|
||||
}
|
||||
if i < len(s) {
|
||||
if s[i] != '.' {
|
||||
return nil, fmt.Errorf("unexpected character in table header")
|
||||
}
|
||||
i++
|
||||
}
|
||||
}
|
||||
return segs, nil
|
||||
}
|
||||
|
||||
// parseValue parses a basic string, integer or boolean.
|
||||
func parseValue(v string) (any, error) {
|
||||
if v == "" {
|
||||
return nil, fmt.Errorf("missing value")
|
||||
}
|
||||
switch v[0] {
|
||||
case '"':
|
||||
s, rest, err := parseBasicString(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(rest) != "" {
|
||||
return nil, fmt.Errorf("trailing characters after string value")
|
||||
}
|
||||
return s, nil
|
||||
case '\'':
|
||||
end := strings.IndexByte(v[1:], '\'')
|
||||
if end < 0 {
|
||||
return nil, fmt.Errorf("unterminated literal string")
|
||||
}
|
||||
s := v[1 : 1+end]
|
||||
if strings.TrimSpace(v[2+end:]) != "" {
|
||||
return nil, fmt.Errorf("trailing characters after string value")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
switch v {
|
||||
case "true":
|
||||
return true, nil
|
||||
case "false":
|
||||
return false, nil
|
||||
}
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unsupported value (expected string, int or bool)")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// parseBasicString parses one "..." basic string with \\ \" \n \t \r
|
||||
// escapes; it returns the string and the unparsed remainder.
|
||||
func parseBasicString(v string) (string, string, error) {
|
||||
var b strings.Builder
|
||||
for i := 1; i < len(v); i++ {
|
||||
switch v[i] {
|
||||
case '"':
|
||||
return b.String(), v[i+1:], nil
|
||||
case '\\':
|
||||
if i+1 >= len(v) {
|
||||
return "", "", fmt.Errorf("dangling escape in string")
|
||||
}
|
||||
i++
|
||||
switch v[i] {
|
||||
case '\\':
|
||||
b.WriteByte('\\')
|
||||
case '"':
|
||||
b.WriteByte('"')
|
||||
case 'n':
|
||||
b.WriteByte('\n')
|
||||
case 't':
|
||||
b.WriteByte('\t')
|
||||
case 'r':
|
||||
b.WriteByte('\r')
|
||||
default:
|
||||
return "", "", fmt.Errorf("unsupported escape in string")
|
||||
}
|
||||
default:
|
||||
b.WriteByte(v[i])
|
||||
}
|
||||
}
|
||||
return "", "", fmt.Errorf("unterminated string")
|
||||
}
|
||||
|
||||
// stripComment removes a trailing # comment that sits outside quotes.
|
||||
func stripComment(line string) string {
|
||||
var quote byte
|
||||
for i := 0; i < len(line); i++ {
|
||||
c := line[i]
|
||||
switch {
|
||||
case quote != 0:
|
||||
if c == quote {
|
||||
quote = 0
|
||||
}
|
||||
case c == '"' || c == '\'':
|
||||
quote = c
|
||||
case c == '#':
|
||||
return line[:i]
|
||||
}
|
||||
}
|
||||
return line
|
||||
}
|
||||
Reference in New Issue
Block a user